feat: partial consumption tracking and waste/disposal logging (#12 #60)

#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
This commit is contained in:
pyr0ball 2026-04-16 07:28:21 -07:00
parent 443e68ba3f
commit fb18a9c78c
8 changed files with 539 additions and 23 deletions

View file

@ -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",
)

View file

@ -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;

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -0,0 +1,275 @@
<template>
<Transition name="modal">
<div v-if="show" class="modal-overlay" @click="handleCancel">
<div class="modal-container" @click.stop>
<div class="modal-header">
<h3>{{ title }}</h3>
</div>
<div class="modal-body">
<p>{{ message }}</p>
<!-- Partial quantity input -->
<div v-if="inputType === 'quantity'" class="action-input-row">
<label class="action-input-label">{{ inputLabel }}</label>
<div class="qty-input-group">
<input
v-model.number="inputNumber"
type="number"
:min="0.01"
:max="inputMax"
step="0.5"
class="action-number-input"
:aria-label="inputLabel"
/>
<span class="qty-unit">{{ inputUnit }}</span>
</div>
<button class="btn-use-all" @click="inputNumber = inputMax">
Use all ({{ inputMax }} {{ inputUnit }})
</button>
</div>
<!-- Reason select -->
<div v-if="inputType === 'select'" class="action-input-row">
<label class="action-input-label">{{ inputLabel }}</label>
<select v-model="inputSelect" class="action-select" :aria-label="inputLabel">
<option value=""> skip </option>
<option v-for="opt in inputOptions" :key="opt" :value="opt">{{ opt }}</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="handleCancel">Cancel</button>
<button :class="['btn', `btn-${type}`]" @click="handleConfirm">
{{ confirmText }}
</button>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
interface Props {
show: boolean
title?: string
message: string
confirmText?: string
type?: 'primary' | 'danger' | 'warning' | 'secondary'
inputType?: 'quantity' | 'select' | null
inputLabel?: string
inputMax?: number
inputUnit?: string
inputOptions?: string[]
}
const props = withDefaults(defineProps<Props>(), {
title: 'Confirm',
confirmText: 'Confirm',
type: 'primary',
inputType: null,
inputLabel: '',
inputMax: 1,
inputUnit: '',
inputOptions: () => [],
})
const emit = defineEmits<{
confirm: [value: number | string | undefined]
cancel: []
}>()
const inputNumber = ref<number>(props.inputMax)
const inputSelect = ref<string>('')
watch(() => props.inputMax, (v) => { inputNumber.value = v })
watch(() => props.show, (v) => {
if (v) {
inputNumber.value = props.inputMax
inputSelect.value = ''
}
})
function handleConfirm() {
if (props.inputType === 'quantity') {
const qty = Math.min(Math.max(0.01, inputNumber.value || props.inputMax), props.inputMax)
emit('confirm', qty)
} else if (props.inputType === 'select') {
emit('confirm', inputSelect.value || undefined)
} else {
emit('confirm', undefined)
}
}
function handleCancel() {
emit('cancel')
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: var(--spacing-lg);
}
.modal-container {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
max-width: 480px;
width: 100%;
overflow: hidden;
}
.modal-header {
padding: var(--spacing-lg);
border-bottom: 1px solid var(--color-border);
}
.modal-header h3 {
margin: 0;
color: var(--color-text-primary);
font-size: var(--font-size-lg);
font-weight: 600;
}
.modal-body {
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.modal-body p {
margin: 0;
color: var(--color-text-primary);
font-size: var(--font-size-base);
line-height: 1.5;
}
.action-input-row {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.action-input-label {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
font-weight: 500;
}
.qty-input-group {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.action-number-input {
width: 90px;
padding: var(--spacing-xs) var(--spacing-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: var(--font-size-base);
}
.qty-unit {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.btn-use-all {
align-self: flex-start;
background: none;
border: none;
color: var(--color-primary);
font-size: var(--font-size-sm);
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.action-select {
padding: var(--spacing-xs) var(--spacing-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: var(--font-size-base);
width: 100%;
}
.modal-footer {
padding: var(--spacing-lg);
border-top: 1px solid var(--color-border);
display: flex;
justify-content: flex-end;
gap: var(--spacing-md);
}
.btn {
padding: var(--spacing-sm) var(--spacing-lg);
border: none;
border-radius: var(--radius-md);
font-size: var(--font-size-base);
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-secondary {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
}
.btn-secondary:hover { background: var(--color-bg-primary); }
.btn-primary {
background: var(--gradient-primary);
color: white;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-danger {
background: var(--color-error);
color: white;
}
.btn-danger:hover {
background: var(--color-error-dark);
transform: translateY(-1px);
}
.btn-warning {
background: var(--color-warning);
color: white;
}
/* Animations */
.modal-enter-active,
.modal-leave-active { transition: opacity 0.3s ease; }
.modal-enter-active .modal-container,
.modal-leave-active .modal-container { transition: transform 0.3s ease; }
.modal-enter-from,
.modal-leave-to { opacity: 0; }
.modal-enter-from .modal-container,
.modal-leave-to .modal-container { transform: scale(0.9) translateY(-20px); }
</style>

View file

@ -354,11 +354,28 @@
<circle cx="10" cy="13" r="1.5" fill="currentColor"/>
</svg>
</button>
<button @click="markAsConsumed(item)" class="btn-icon btn-icon-success" aria-label="Mark consumed">
<button
v-if="item.status === 'available'"
@click="markAsConsumed(item)"
class="btn-icon btn-icon-success"
:aria-label="item.quantity > 1 ? `Use some (${item.quantity} ${item.unit})` : 'Mark as used'"
:title="item.quantity > 1 ? `Use some or all (${item.quantity} ${item.unit})` : 'Mark as used'"
>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<polyline points="4 10 8 14 16 6"/>
</svg>
</button>
<button
v-if="item.status === 'available'"
@click="markAsDiscarded(item)"
class="btn-icon btn-icon-discard"
aria-label="Mark as not used"
title="I didn't use this (went bad, too much, etc)"
>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<path d="M4 4l12 12M4 16L16 4"/>
</svg>
</button>
<button @click="confirmDelete(item)" class="btn-icon btn-icon-danger" aria-label="Delete">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<polyline points="3 6 5 6 17 6"/>
@ -400,6 +417,22 @@
@cancel="confirmDialog.show = false"
/>
<!-- Action Dialog (partial consume / discard reason) -->
<ActionDialog
:show="actionDialog.show"
:title="actionDialog.title"
:message="actionDialog.message"
:type="actionDialog.type"
:confirm-text="actionDialog.confirmText"
:input-type="actionDialog.inputType"
:input-label="actionDialog.inputLabel"
:input-max="actionDialog.inputMax"
:input-unit="actionDialog.inputUnit"
:input-options="actionDialog.inputOptions"
@confirm="(v) => { actionDialog.onConfirm(v); actionDialog.show = false }"
@cancel="actionDialog.show = false"
/>
<!-- Toast Notification -->
<ToastNotification
:show="toast.show"
@ -421,6 +454,7 @@ import type { InventoryItem } from '../services/api'
import { formatQuantity } from '../utils/units'
import EditItemModal from './EditItemModal.vue'
import ConfirmDialog from './ConfirmDialog.vue'
import ActionDialog from './ActionDialog.vue'
import ToastNotification from './ToastNotification.vue'
const store = useInventoryStore()
@ -466,6 +500,20 @@ const confirmDialog = reactive({
onConfirm: () => {},
})
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;

View file

@ -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<void> {
await api.post(`/inventory/items/${itemId}/consume`)
async consumeItem(itemId: number, quantity?: number): Promise<InventoryItem> {
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<InventoryItem> {
const response = await api.post(`/inventory/items/${itemId}/discard`, { reason: reason ?? null })
return response.data
},
/**