feat(products): improve replenishment-aware shopping suggestions
Replace product weight and repurchase intent fields with per-package remaining levels and inventory-first restock signals. Enrich shopping suggestions with usage-aware replenishment scoring so the frontend and LLM can prioritize real gaps and near-empty staples more reliably.
This commit is contained in:
parent
bb5d402c15
commit
d91d06455b
18 changed files with 587 additions and 210 deletions
|
|
@ -0,0 +1,69 @@
|
||||||
|
"""replace product weights with inventory remaining level
|
||||||
|
|
||||||
|
Revision ID: 9f3a2c1b4d5e
|
||||||
|
Revises: 7e6f73d1cc95
|
||||||
|
Create Date: 2026-03-08 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "9f3a2c1b4d5e"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "7e6f73d1cc95"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
remaining_level_enum = sa.Enum(
|
||||||
|
"HIGH",
|
||||||
|
"MEDIUM",
|
||||||
|
"LOW",
|
||||||
|
"NEARLY_EMPTY",
|
||||||
|
name="remaininglevel",
|
||||||
|
)
|
||||||
|
remaining_level_enum.create(bind, checkfirst=True)
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"product_inventory",
|
||||||
|
sa.Column("remaining_level", remaining_level_enum, nullable=True),
|
||||||
|
)
|
||||||
|
op.drop_column("product_inventory", "last_weighed_at")
|
||||||
|
op.drop_column("product_inventory", "current_weight_g")
|
||||||
|
op.drop_column("products", "personal_repurchase_intent")
|
||||||
|
op.drop_column("products", "empty_weight_g")
|
||||||
|
op.drop_column("products", "full_weight_g")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
remaining_level_enum = sa.Enum(
|
||||||
|
"HIGH",
|
||||||
|
"MEDIUM",
|
||||||
|
"LOW",
|
||||||
|
"NEARLY_EMPTY",
|
||||||
|
name="remaininglevel",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"products",
|
||||||
|
sa.Column("personal_repurchase_intent", sa.Boolean(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"product_inventory",
|
||||||
|
sa.Column("current_weight_g", sa.Float(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"product_inventory",
|
||||||
|
sa.Column("last_weighed_at", sa.Date(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column("products", sa.Column("full_weight_g", sa.Float(), nullable=True))
|
||||||
|
op.add_column("products", sa.Column("empty_weight_g", sa.Float(), nullable=True))
|
||||||
|
op.drop_column("product_inventory", "remaining_level")
|
||||||
|
remaining_level_enum.drop(bind, checkfirst=True)
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Any, Literal, Optional
|
from typing import Any, Literal, Optional, cast
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
@ -48,6 +48,7 @@ from innercontext.models.enums import (
|
||||||
AbsorptionSpeed,
|
AbsorptionSpeed,
|
||||||
DayTime,
|
DayTime,
|
||||||
PriceTier,
|
PriceTier,
|
||||||
|
RemainingLevel,
|
||||||
SkinType,
|
SkinType,
|
||||||
TextureType,
|
TextureType,
|
||||||
)
|
)
|
||||||
|
|
@ -128,8 +129,6 @@ class ProductUpdate(SQLModel):
|
||||||
price_amount: Optional[float] = None
|
price_amount: Optional[float] = None
|
||||||
price_currency: Optional[str] = None
|
price_currency: Optional[str] = None
|
||||||
size_ml: Optional[float] = None
|
size_ml: Optional[float] = None
|
||||||
full_weight_g: Optional[float] = None
|
|
||||||
empty_weight_g: Optional[float] = None
|
|
||||||
pao_months: Optional[int] = None
|
pao_months: Optional[int] = None
|
||||||
|
|
||||||
inci: Optional[list[str]] = None
|
inci: Optional[list[str]] = None
|
||||||
|
|
@ -159,7 +158,6 @@ class ProductUpdate(SQLModel):
|
||||||
needle_length_mm: Optional[float] = None
|
needle_length_mm: Optional[float] = None
|
||||||
|
|
||||||
personal_tolerance_notes: Optional[str] = None
|
personal_tolerance_notes: Optional[str] = None
|
||||||
personal_repurchase_intent: Optional[bool] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProductParseRequest(SQLModel):
|
class ProductParseRequest(SQLModel):
|
||||||
|
|
@ -181,8 +179,6 @@ class ProductParseResponse(SQLModel):
|
||||||
price_amount: Optional[float] = None
|
price_amount: Optional[float] = None
|
||||||
price_currency: Optional[str] = None
|
price_currency: Optional[str] = None
|
||||||
size_ml: Optional[float] = None
|
size_ml: Optional[float] = None
|
||||||
full_weight_g: Optional[float] = None
|
|
||||||
empty_weight_g: Optional[float] = None
|
|
||||||
pao_months: Optional[int] = None
|
pao_months: Optional[int] = None
|
||||||
inci: Optional[list[str]] = None
|
inci: Optional[list[str]] = None
|
||||||
actives: Optional[list[ActiveIngredient]] = None
|
actives: Optional[list[ActiveIngredient]] = None
|
||||||
|
|
@ -234,8 +230,7 @@ class InventoryCreate(SQLModel):
|
||||||
opened_at: Optional[date] = None
|
opened_at: Optional[date] = None
|
||||||
finished_at: Optional[date] = None
|
finished_at: Optional[date] = None
|
||||||
expiry_date: Optional[date] = None
|
expiry_date: Optional[date] = None
|
||||||
current_weight_g: Optional[float] = None
|
remaining_level: Optional[RemainingLevel] = None
|
||||||
last_weighed_at: Optional[date] = None
|
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -244,11 +239,173 @@ class InventoryUpdate(SQLModel):
|
||||||
opened_at: Optional[date] = None
|
opened_at: Optional[date] = None
|
||||||
finished_at: Optional[date] = None
|
finished_at: Optional[date] = None
|
||||||
expiry_date: Optional[date] = None
|
expiry_date: Optional[date] = None
|
||||||
current_weight_g: Optional[float] = None
|
remaining_level: Optional[RemainingLevel] = None
|
||||||
last_weighed_at: Optional[date] = None
|
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _remaining_level_rank(level: RemainingLevel | str | None) -> int:
|
||||||
|
if level in (RemainingLevel.NEARLY_EMPTY, "nearly_empty"):
|
||||||
|
return 0
|
||||||
|
if level in (RemainingLevel.LOW, "low"):
|
||||||
|
return 1
|
||||||
|
if level in (RemainingLevel.MEDIUM, "medium"):
|
||||||
|
return 2
|
||||||
|
if level in (RemainingLevel.HIGH, "high"):
|
||||||
|
return 3
|
||||||
|
return 99
|
||||||
|
|
||||||
|
|
||||||
|
_STAPLE_CATEGORIES = {"cleanser", "moisturizer", "spf"}
|
||||||
|
_OCCASIONAL_CATEGORIES = {"exfoliant", "mask", "spot_treatment", "tool"}
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_days_since_last_used(
|
||||||
|
last_used_on: date | None, reference_date: date
|
||||||
|
) -> int | None:
|
||||||
|
if last_used_on is None:
|
||||||
|
return None
|
||||||
|
return max((reference_date - last_used_on).days, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_replenishment_score(
|
||||||
|
*,
|
||||||
|
has_stock: bool,
|
||||||
|
sealed_backup_count: int,
|
||||||
|
lowest_remaining_level: str | None,
|
||||||
|
days_since_last_used: int | None,
|
||||||
|
category: ProductCategory | str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
score = 0
|
||||||
|
reason_codes: list[str] = []
|
||||||
|
category_value = _ev(category)
|
||||||
|
|
||||||
|
if not has_stock:
|
||||||
|
score = 90
|
||||||
|
reason_codes.append("out_of_stock")
|
||||||
|
elif sealed_backup_count > 0:
|
||||||
|
score = 10
|
||||||
|
reason_codes.append("has_sealed_backup")
|
||||||
|
elif lowest_remaining_level == "nearly_empty":
|
||||||
|
score = 80
|
||||||
|
reason_codes.append("nearly_empty_opened")
|
||||||
|
elif lowest_remaining_level == "low":
|
||||||
|
score = 60
|
||||||
|
reason_codes.append("low_opened")
|
||||||
|
elif lowest_remaining_level == "medium":
|
||||||
|
score = 25
|
||||||
|
elif lowest_remaining_level == "high":
|
||||||
|
score = 5
|
||||||
|
else:
|
||||||
|
reason_codes.append("insufficient_remaining_data")
|
||||||
|
|
||||||
|
if days_since_last_used is not None:
|
||||||
|
if days_since_last_used <= 3:
|
||||||
|
score += 20
|
||||||
|
reason_codes.append("recently_used")
|
||||||
|
elif days_since_last_used <= 7:
|
||||||
|
score += 12
|
||||||
|
reason_codes.append("recently_used")
|
||||||
|
elif days_since_last_used <= 14:
|
||||||
|
score += 6
|
||||||
|
elif days_since_last_used <= 30:
|
||||||
|
pass
|
||||||
|
elif days_since_last_used <= 60:
|
||||||
|
score -= 10
|
||||||
|
reason_codes.append("stale_usage")
|
||||||
|
else:
|
||||||
|
score -= 20
|
||||||
|
reason_codes.append("stale_usage")
|
||||||
|
|
||||||
|
if category_value in _STAPLE_CATEGORIES:
|
||||||
|
score += 15
|
||||||
|
reason_codes.append("staple_category")
|
||||||
|
elif category_value in _OCCASIONAL_CATEGORIES:
|
||||||
|
score -= 10
|
||||||
|
reason_codes.append("occasional_category")
|
||||||
|
elif category_value == "serum":
|
||||||
|
score += 5
|
||||||
|
|
||||||
|
if sealed_backup_count > 0 and has_stock:
|
||||||
|
score = min(score, 15)
|
||||||
|
if (
|
||||||
|
days_since_last_used is not None
|
||||||
|
and days_since_last_used > 60
|
||||||
|
and category_value not in _STAPLE_CATEGORIES
|
||||||
|
):
|
||||||
|
score = min(score, 25)
|
||||||
|
if (
|
||||||
|
lowest_remaining_level is None
|
||||||
|
and has_stock
|
||||||
|
and (days_since_last_used is None or days_since_last_used > 14)
|
||||||
|
):
|
||||||
|
score = min(score, 20)
|
||||||
|
|
||||||
|
score = max(0, min(score, 100))
|
||||||
|
if score >= 80:
|
||||||
|
priority_hint = "high"
|
||||||
|
elif score >= 50:
|
||||||
|
priority_hint = "medium"
|
||||||
|
elif score >= 25:
|
||||||
|
priority_hint = "low"
|
||||||
|
else:
|
||||||
|
priority_hint = "none"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"replenishment_score": score,
|
||||||
|
"replenishment_priority_hint": priority_hint,
|
||||||
|
"repurchase_candidate": priority_hint != "none",
|
||||||
|
"replenishment_reason_codes": reason_codes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_inventory_state(entries: list[ProductInventory]) -> dict[str, object]:
|
||||||
|
active_entries = [entry for entry in entries if entry.finished_at is None]
|
||||||
|
opened_entries = [entry for entry in active_entries if entry.is_opened]
|
||||||
|
sealed_entries = [entry for entry in active_entries if not entry.is_opened]
|
||||||
|
|
||||||
|
opened_levels = [
|
||||||
|
_ev(entry.remaining_level)
|
||||||
|
for entry in opened_entries
|
||||||
|
if entry.remaining_level is not None
|
||||||
|
]
|
||||||
|
opened_levels_sorted = sorted(
|
||||||
|
opened_levels,
|
||||||
|
key=_remaining_level_rank,
|
||||||
|
)
|
||||||
|
lowest_opened_level = opened_levels_sorted[0] if opened_levels_sorted else None
|
||||||
|
|
||||||
|
stock_state = "healthy"
|
||||||
|
if not active_entries:
|
||||||
|
stock_state = "out_of_stock"
|
||||||
|
elif sealed_entries:
|
||||||
|
stock_state = "healthy"
|
||||||
|
elif lowest_opened_level == "nearly_empty":
|
||||||
|
stock_state = "urgent"
|
||||||
|
elif lowest_opened_level == "low":
|
||||||
|
stock_state = "low"
|
||||||
|
elif lowest_opened_level == "medium":
|
||||||
|
stock_state = "monitor"
|
||||||
|
|
||||||
|
replenishment_signal = "none"
|
||||||
|
if stock_state == "out_of_stock":
|
||||||
|
replenishment_signal = "out_of_stock"
|
||||||
|
elif stock_state == "urgent":
|
||||||
|
replenishment_signal = "urgent"
|
||||||
|
elif stock_state == "low":
|
||||||
|
replenishment_signal = "soon"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"has_stock": bool(active_entries),
|
||||||
|
"active_count": len(active_entries),
|
||||||
|
"opened_count": len(opened_entries),
|
||||||
|
"sealed_backup_count": len(sealed_entries),
|
||||||
|
"opened_levels": opened_levels_sorted,
|
||||||
|
"lowest_opened_level": lowest_opened_level,
|
||||||
|
"stock_state": stock_state,
|
||||||
|
"replenishment_signal": replenishment_signal,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Shopping suggestion schemas
|
# Shopping suggestion schemas
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -324,15 +481,6 @@ def _estimated_amount_per_use(category: ProductCategory) -> float | None:
|
||||||
return _ESTIMATED_AMOUNT_PER_USE.get(category)
|
return _ESTIMATED_AMOUNT_PER_USE.get(category)
|
||||||
|
|
||||||
|
|
||||||
def _net_weight_g(product: Product) -> float | None:
|
|
||||||
if product.full_weight_g is None or product.empty_weight_g is None:
|
|
||||||
return None
|
|
||||||
net = product.full_weight_g - product.empty_weight_g
|
|
||||||
if net <= 0:
|
|
||||||
return None
|
|
||||||
return net
|
|
||||||
|
|
||||||
|
|
||||||
def _price_per_use_pln(product: Product) -> float | None:
|
def _price_per_use_pln(product: Product) -> float | None:
|
||||||
if product.price_amount is None or product.price_currency is None:
|
if product.price_amount is None or product.price_currency is None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -342,8 +490,6 @@ def _price_per_use_pln(product: Product) -> float | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
pack_amount = product.size_ml
|
pack_amount = product.size_ml
|
||||||
if pack_amount is None or pack_amount <= 0:
|
|
||||||
pack_amount = _net_weight_g(product)
|
|
||||||
if pack_amount is None or pack_amount <= 0:
|
if pack_amount is None or pack_amount <= 0:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -518,8 +664,10 @@ def create_product(data: ProductCreate, session: Session = Depends(get_session))
|
||||||
if payload.get("price_currency"):
|
if payload.get("price_currency"):
|
||||||
payload["price_currency"] = str(payload["price_currency"]).upper()
|
payload["price_currency"] = str(payload["price_currency"]).upper()
|
||||||
|
|
||||||
|
product_id = uuid4()
|
||||||
product = Product(
|
product = Product(
|
||||||
id=uuid4(),
|
id=product_id,
|
||||||
|
short_id=str(product_id)[:8],
|
||||||
**payload,
|
**payload,
|
||||||
)
|
)
|
||||||
session.add(product)
|
session.add(product)
|
||||||
|
|
@ -599,8 +747,6 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine):
|
||||||
"price_amount": number,
|
"price_amount": number,
|
||||||
"price_currency": string,
|
"price_currency": string,
|
||||||
"size_ml": number,
|
"size_ml": number,
|
||||||
"full_weight_g": number,
|
|
||||||
"empty_weight_g": number,
|
|
||||||
"pao_months": integer,
|
"pao_months": integer,
|
||||||
"inci": [string, ...],
|
"inci": [string, ...],
|
||||||
"actives": [
|
"actives": [
|
||||||
|
|
@ -869,7 +1015,13 @@ def _ev(v: object) -> str:
|
||||||
return str(v)
|
return str(v)
|
||||||
|
|
||||||
|
|
||||||
def _build_shopping_context(session: Session, reference_date: date) -> str:
|
def _build_shopping_context(
|
||||||
|
session: Session,
|
||||||
|
reference_date: date,
|
||||||
|
*,
|
||||||
|
products: list[Product] | None = None,
|
||||||
|
last_used_on_by_product: dict[str, date] | None = None,
|
||||||
|
) -> str:
|
||||||
profile_ctx = build_user_profile_context(session, reference_date=reference_date)
|
profile_ctx = build_user_profile_context(session, reference_date=reference_date)
|
||||||
snapshot = session.exec(
|
snapshot = session.exec(
|
||||||
select(SkinConditionSnapshot).order_by(
|
select(SkinConditionSnapshot).order_by(
|
||||||
|
|
@ -892,9 +1044,14 @@ def _build_shopping_context(session: Session, reference_date: date) -> str:
|
||||||
else:
|
else:
|
||||||
skin_lines.append(" (brak danych)")
|
skin_lines.append(" (brak danych)")
|
||||||
|
|
||||||
|
if products is None:
|
||||||
products = _get_shopping_products(session)
|
products = _get_shopping_products(session)
|
||||||
|
|
||||||
product_ids = [p.id for p in products]
|
product_ids = [p.id for p in products]
|
||||||
|
last_used_on_by_product = last_used_on_by_product or build_last_used_on_by_product(
|
||||||
|
session,
|
||||||
|
product_ids=product_ids,
|
||||||
|
)
|
||||||
inventory_rows = (
|
inventory_rows = (
|
||||||
session.exec(
|
session.exec(
|
||||||
select(ProductInventory).where(
|
select(ProductInventory).where(
|
||||||
|
|
@ -910,15 +1067,31 @@ def _build_shopping_context(session: Session, reference_date: date) -> str:
|
||||||
|
|
||||||
products_lines = ["POSIADANE PRODUKTY:"]
|
products_lines = ["POSIADANE PRODUKTY:"]
|
||||||
products_lines.append(
|
products_lines.append(
|
||||||
" Legenda: [✓] = produkt dostępny (w magazynie), [✗] = brak w magazynie"
|
" Legenda: [✓] = aktywny zapas istnieje, [✗] = brak aktywnego zapasu"
|
||||||
|
)
|
||||||
|
products_lines.append(
|
||||||
|
" Pola: stock_state, sealed_backup_count, lowest_remaining_level, days_since_last_used, replenishment_score, replenishment_priority_hint, repurchase_candidate"
|
||||||
)
|
)
|
||||||
for p in products:
|
for p in products:
|
||||||
active_inv = [i for i in inv_by_product.get(p.id, []) if i.finished_at is None]
|
inventory_summary = _summarize_inventory_state(inv_by_product.get(p.id, []))
|
||||||
has_stock = len(active_inv) > 0 # any unfinished inventory = in stock
|
stock = "✓" if inventory_summary["has_stock"] else "✗"
|
||||||
stock = "✓" if has_stock else "✗"
|
last_used_on = last_used_on_by_product.get(str(p.id))
|
||||||
|
days_since_last_used = _compute_days_since_last_used(
|
||||||
|
last_used_on, reference_date
|
||||||
|
)
|
||||||
|
replenishment = _compute_replenishment_score(
|
||||||
|
has_stock=bool(inventory_summary["has_stock"]),
|
||||||
|
sealed_backup_count=cast(int, inventory_summary["sealed_backup_count"]),
|
||||||
|
lowest_remaining_level=(
|
||||||
|
str(inventory_summary["lowest_opened_level"])
|
||||||
|
if inventory_summary["lowest_opened_level"] is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
days_since_last_used=days_since_last_used,
|
||||||
|
category=p.category,
|
||||||
|
)
|
||||||
|
|
||||||
actives = _extract_active_names(p)
|
actives = _extract_active_names(p)
|
||||||
actives_str = f", actives: {actives}" if actives else ""
|
|
||||||
|
|
||||||
ep = p.product_effect_profile
|
ep = p.product_effect_profile
|
||||||
if isinstance(ep, dict):
|
if isinstance(ep, dict):
|
||||||
|
|
@ -929,13 +1102,55 @@ def _build_shopping_context(session: Session, reference_date: date) -> str:
|
||||||
for k, v in ep.model_dump().items()
|
for k, v in ep.model_dump().items()
|
||||||
if v >= 3
|
if v >= 3
|
||||||
}
|
}
|
||||||
effects_str = f", effects: {effects}" if effects else ""
|
|
||||||
|
|
||||||
targets = [_ev(t) for t in (p.targets or [])]
|
targets = [_ev(t) for t in (p.targets or [])]
|
||||||
|
product_header = f" [{stock}] id={p.id} {p.name}"
|
||||||
|
if p.brand:
|
||||||
|
product_header += f" ({p.brand})"
|
||||||
|
products_lines.append(product_header)
|
||||||
|
products_lines.append(f" category={_ev(p.category)}")
|
||||||
|
products_lines.append(f" targets={targets}")
|
||||||
|
if actives:
|
||||||
|
products_lines.append(f" actives={actives}")
|
||||||
|
if effects:
|
||||||
|
products_lines.append(f" effects={effects}")
|
||||||
|
products_lines.append(f" stock_state={inventory_summary['stock_state']}")
|
||||||
|
products_lines.append(f" active_count={inventory_summary['active_count']}")
|
||||||
|
products_lines.append(f" opened_count={inventory_summary['opened_count']}")
|
||||||
products_lines.append(
|
products_lines.append(
|
||||||
f" [{stock}] id={p.id} {p.name} ({p.brand or ''}) - {_ev(p.category)}, "
|
f" sealed_backup_count={inventory_summary['sealed_backup_count']}"
|
||||||
f"targets: {targets}{actives_str}{effects_str}"
|
)
|
||||||
|
products_lines.append(
|
||||||
|
" lowest_remaining_level="
|
||||||
|
+ (
|
||||||
|
str(inventory_summary["lowest_opened_level"])
|
||||||
|
if inventory_summary["lowest_opened_level"] is not None
|
||||||
|
else "null"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
products_lines.append(
|
||||||
|
" last_used_on=" + (last_used_on.isoformat() if last_used_on else "null")
|
||||||
|
)
|
||||||
|
products_lines.append(
|
||||||
|
" days_since_last_used="
|
||||||
|
+ (
|
||||||
|
str(days_since_last_used)
|
||||||
|
if days_since_last_used is not None
|
||||||
|
else "null"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
products_lines.append(
|
||||||
|
f" replenishment_score={replenishment['replenishment_score']}"
|
||||||
|
)
|
||||||
|
products_lines.append(
|
||||||
|
" replenishment_priority_hint="
|
||||||
|
+ str(replenishment["replenishment_priority_hint"])
|
||||||
|
)
|
||||||
|
products_lines.append(
|
||||||
|
" repurchase_candidate="
|
||||||
|
+ ("true" if replenishment["repurchase_candidate"] else "false")
|
||||||
|
)
|
||||||
|
products_lines.append(
|
||||||
|
" reason_codes=" + str(replenishment["replenishment_reason_codes"])
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -973,32 +1188,46 @@ def _extract_requested_product_ids(
|
||||||
|
|
||||||
|
|
||||||
_SHOPPING_SYSTEM_PROMPT = """Jesteś asystentem zakupowym w dziedzinie pielęgnacji skóry.
|
_SHOPPING_SYSTEM_PROMPT = """Jesteś asystentem zakupowym w dziedzinie pielęgnacji skóry.
|
||||||
Twoim zadaniem jest przeanalizować stan skóry użytkownika oraz produkty, które już posiada,
|
|
||||||
a następnie zasugerować TYPY produktów (bez marek), które mogłyby uzupełnić ich rutynę.
|
|
||||||
|
|
||||||
LEGENDA:
|
Twoim zadaniem jest ocenić dwie rzeczy:
|
||||||
- [✓] = produkt dostępny w magazynie (nawet jeśli jest zapieczętowany)
|
1. czy w rutynie użytkownika istnieją realne luki produktowe,
|
||||||
- [✗] = produkt niedostępny (brak w magazynie, wszystkie opakowania zużyte)
|
2. czy któryś z już posiadanych typów produktów warto odkupić teraz z powodu kończącego się zapasu.
|
||||||
|
|
||||||
ZASADY:
|
Masz działać konserwatywnie: nie sugeruj zakupu tylko dlatego, że coś mogłoby się przydać.
|
||||||
|
Sugestia ma pojawić się tylko wtedy, gdy istnieje wyraźny powód praktyczny.
|
||||||
|
|
||||||
|
WAŻNE POJĘCIA:
|
||||||
|
- `stock_state` opisuje stan zapasu: `out_of_stock`, `healthy`, `monitor`, `low`, `urgent`
|
||||||
|
- `sealed_backup_count` > 0 zwykle oznacza, że odkup nie jest pilny
|
||||||
|
- `lowest_remaining_level` dotyczy tylko otwartych opakowań i może mieć wartość: `high`, `medium`, `low`, `nearly_empty`
|
||||||
|
- `last_used_on` i `days_since_last_used` pomagają ocenić, czy produkt jest nadal faktycznie używany
|
||||||
|
- `replenishment_score`, `replenishment_priority_hint` i `repurchase_candidate` są backendowym sygnałem pilności odkupu; traktuj je jako mocną wskazówkę
|
||||||
|
- `reason_codes` tłumaczą, dlaczego backend uznał odkup za pilny albo niepilny
|
||||||
|
|
||||||
|
JAK PODEJMOWAĆ DECYZJĘ:
|
||||||
0. Sugeruj tylko wtedy, gdy jest realna potrzeba - nie zwracaj stałej liczby produktów
|
0. Sugeruj tylko wtedy, gdy jest realna potrzeba - nie zwracaj stałej liczby produktów
|
||||||
1. Sugeruj TYLKO typy produktów, NIGDY konkretne marki (np. "Salicylic Acid 2% Masque", nie "La Roche-Posay")
|
1. Najpierw oceń, czy użytkownik ma lukę w rutynie
|
||||||
2. Produkty oznaczone [✗] to te, których NIE MA w magazynie - możesz je zasugerować
|
2. Potem oceń, czy któryś istniejący typ produktu jest wart odkupu teraz
|
||||||
3. Produkty oznaczone [✓] są już dostępne - nie sugeruj ich ponownie
|
3. Nie rekomenduj ponownie produktu, który ma zdrowy zapas
|
||||||
4. Bierz pod uwagę aktywne problemy skóry (acne, hyperpigmentacja, aging, etc.)
|
4. Nie rekomenduj odkupu tylko dlatego, że produkt jest niski, jeśli nie był używany od dawna i nie wygląda na nadal potrzebny
|
||||||
5. Sugeruj realistyczną częstotliwość użycia (dzienna, 2-3x tygodniowo, etc.)
|
5. Dla kategorii podstawowych (`cleanser`, `moisturizer`, `spf`) niski stan i świeże użycie mają większe znaczenie niż dla produktów okazjonalnych
|
||||||
6. Zachowaj kolejność warstw: cleanse → toner → serum → moisturizer → SPF
|
6. Dla produktów okazjonalnych (`exfoliant`, `mask`, `spot_treatment`) świeżość użycia jest ważniejsza niż sam niski stan
|
||||||
7. Jeśli użytkownik ma uszkodzoną barierę, unikaj silnych eksfoliantów i retinoidów
|
7. Jeśli `sealed_backup_count` > 0, zwykle nie rekomenduj odkupu
|
||||||
8. Zwracaj uwagę na ewentualne konflikty polecanych składników z tymi, które użytkownik już posiada (np. nie polecaj peptydów miedziowych jeśli użytkownik nadużywa kwasów)
|
8. Sugeruj TYLKO typy produktów, NIGDY konkretne marki (np. "Salicylic Acid 2% Masque", nie "La Roche-Posay")
|
||||||
9. Odpowiadaj w języku polskim
|
9. Bierz pod uwagę aktywne problemy skóry (acne, hyperpigmentacja, aging, etc.)
|
||||||
10. Używaj wyłącznie dozwolonych wartości enumów poniżej - nie twórz synonimów typu "night", "evening" ani "treatment"
|
10. Sugeruj realistyczną częstotliwość użycia (dzienna, 2-3x tygodniowo, etc.)
|
||||||
11. Możesz zwrócić pustą listę suggestions, jeśli nie widzisz realnej potrzeby zakupowej
|
11. Zachowaj kolejność warstw: cleanse → toner → serum → moisturizer → SPF
|
||||||
12. Każda sugestia ma mieć charakter decision-support: konkretnie wyjaśnij, dlaczego warto kupić ją teraz, jak wpisuje się w obecną rutynę i jakie są ograniczenia
|
12. Jeśli użytkownik ma uszkodzoną barierę, unikaj silnych eksfoliantów i retinoidów
|
||||||
13. `short_reason` ma być krótkim, 1-zdaniowym skrótem decyzji zakupowej
|
13. Zwracaj uwagę na ewentualne konflikty polecanych składników z tymi, które użytkownik już posiada
|
||||||
14. `reason_to_buy_now` ma być konkretne i praktyczne, bez lania wody
|
14. Odpowiadaj w języku polskim
|
||||||
15. `reason_not_needed_if_budget_tight` jest opcjonalne - uzupełniaj tylko wtedy, gdy zakup nie jest pilny lub istnieje rozsądny kompromis
|
15. Używaj wyłącznie dozwolonych wartości enumów poniżej - nie twórz synonimów typu "night", "evening" ani "treatment"
|
||||||
16. `usage_cautions` ma być krótką listą praktycznych uwag; gdy brak istotnych zastrzeżeń zwróć pustą listę
|
16. Możesz zwrócić pustą listę suggestions, jeśli nie widzisz realnej potrzeby zakupowej
|
||||||
17. `priority` ustawiaj jako: high = wyraźna luka lub pilna potrzeba, medium = sensowne uzupełnienie, low = opcjonalny upgrade
|
17. Każda sugestia ma mieć charakter decision-support: konkretnie wyjaśnij, dlaczego warto kupić ją teraz, jak wpisuje się w obecną rutynę i jakie są ograniczenia
|
||||||
|
18. `short_reason` ma być krótkim, 1-zdaniowym skrótem decyzji zakupowej
|
||||||
|
19. `reason_to_buy_now` ma być konkretne i praktyczne, bez lania wody
|
||||||
|
20. `reason_not_needed_if_budget_tight` jest opcjonalne - uzupełniaj tylko wtedy, gdy zakup nie jest pilny lub istnieje rozsądny kompromis
|
||||||
|
21. `usage_cautions` ma być krótką listą praktycznych uwag; gdy brak istotnych zastrzeżeń zwróć pustą listę
|
||||||
|
22. `priority` ustawiaj jako: high = wyraźna luka lub pilna potrzeba, medium = sensowne uzupełnienie, low = opcjonalny upgrade
|
||||||
|
|
||||||
DOZWOLONE WARTOŚCI ENUMÓW:
|
DOZWOLONE WARTOŚCI ENUMÓW:
|
||||||
- category: "cleanser" | "toner" | "essence" | "serum" | "moisturizer" | "spf" | "mask" | "exfoliant" | "hair_treatment" | "tool" | "spot_treatment" | "oil"
|
- category: "cleanser" | "toner" | "essence" | "serum" | "moisturizer" | "spf" | "mask" | "exfoliant" | "hair_treatment" | "tool" | "spot_treatment" | "oil"
|
||||||
|
|
@ -1009,16 +1238,24 @@ Format odpowiedzi - zwróć wyłącznie JSON zgodny z podanym schematem."""
|
||||||
|
|
||||||
@router.post("/suggest", response_model=ShoppingSuggestionResponse)
|
@router.post("/suggest", response_model=ShoppingSuggestionResponse)
|
||||||
def suggest_shopping(session: Session = Depends(get_session)):
|
def suggest_shopping(session: Session = Depends(get_session)):
|
||||||
context = _build_shopping_context(session, reference_date=date.today())
|
reference_date = date.today()
|
||||||
shopping_products = _get_shopping_products(session)
|
shopping_products = _get_shopping_products(session)
|
||||||
last_used_on_by_product = build_last_used_on_by_product(
|
last_used_on_by_product = build_last_used_on_by_product(
|
||||||
session,
|
session,
|
||||||
product_ids=[p.id for p in shopping_products],
|
product_ids=[p.id for p in shopping_products],
|
||||||
)
|
)
|
||||||
|
context = _build_shopping_context(
|
||||||
|
session,
|
||||||
|
reference_date=reference_date,
|
||||||
|
products=shopping_products,
|
||||||
|
last_used_on_by_product=last_used_on_by_product,
|
||||||
|
)
|
||||||
|
|
||||||
prompt = (
|
prompt = (
|
||||||
f"Na podstawie poniższych danych przeanalizuj, jakie TYPY produktów "
|
"Przeanalizuj dane użytkownika i zaproponuj tylko te zakupy, które mają realny sens teraz.\n\n"
|
||||||
f"mogłyby uzupełnić rutynę pielęgnacyjną użytkownika.\n\n"
|
"Najpierw rozważ luki w rutynie, potem ewentualny odkup kończących się produktów.\n"
|
||||||
|
"Jeśli produkt już istnieje, ale ma niski zapas i jest nadal realnie używany, możesz zasugerować odkup tego typu produktu.\n"
|
||||||
|
"Jeśli produkt ma sealed backup albo nie był używany od dawna, zwykle nie sugeruj odkupu.\n\n"
|
||||||
f"{context}\n\n"
|
f"{context}\n\n"
|
||||||
"NARZEDZIA:\n"
|
"NARZEDZIA:\n"
|
||||||
"- Masz dostep do funkcji: get_product_details.\n"
|
"- Masz dostep do funkcji: get_product_details.\n"
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from .enums import (
|
||||||
PartOfDay,
|
PartOfDay,
|
||||||
PriceTier,
|
PriceTier,
|
||||||
ProductCategory,
|
ProductCategory,
|
||||||
|
RemainingLevel,
|
||||||
ResultFlag,
|
ResultFlag,
|
||||||
RoutineRole,
|
RoutineRole,
|
||||||
SexAtBirth,
|
SexAtBirth,
|
||||||
|
|
@ -58,6 +59,7 @@ __all__ = [
|
||||||
"OverallSkinState",
|
"OverallSkinState",
|
||||||
"PartOfDay",
|
"PartOfDay",
|
||||||
"PriceTier",
|
"PriceTier",
|
||||||
|
"RemainingLevel",
|
||||||
"ProductCategory",
|
"ProductCategory",
|
||||||
"ResultFlag",
|
"ResultFlag",
|
||||||
"RoutineRole",
|
"RoutineRole",
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,13 @@ class PriceTier(str, Enum):
|
||||||
LUXURY = "luxury"
|
LUXURY = "luxury"
|
||||||
|
|
||||||
|
|
||||||
|
class RemainingLevel(str, Enum):
|
||||||
|
HIGH = "high"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
LOW = "low"
|
||||||
|
NEARLY_EMPTY = "nearly_empty"
|
||||||
|
|
||||||
|
|
||||||
class EvidenceLevel(str, Enum):
|
class EvidenceLevel(str, Enum):
|
||||||
LOW = "low"
|
LOW = "low"
|
||||||
MIXED = "mixed"
|
MIXED = "mixed"
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from .enums import (
|
||||||
IngredientFunction,
|
IngredientFunction,
|
||||||
PriceTier,
|
PriceTier,
|
||||||
ProductCategory,
|
ProductCategory,
|
||||||
|
RemainingLevel,
|
||||||
SkinConcern,
|
SkinConcern,
|
||||||
SkinType,
|
SkinType,
|
||||||
StrengthLevel,
|
StrengthLevel,
|
||||||
|
|
@ -97,8 +98,6 @@ class ProductBase(SQLModel):
|
||||||
price_amount: float | None = Field(default=None, gt=0)
|
price_amount: float | None = Field(default=None, gt=0)
|
||||||
price_currency: str | None = Field(default=None, min_length=3, max_length=3)
|
price_currency: str | None = Field(default=None, min_length=3, max_length=3)
|
||||||
size_ml: float | None = Field(default=None, gt=0)
|
size_ml: float | None = Field(default=None, gt=0)
|
||||||
full_weight_g: float | None = Field(default=None, gt=0)
|
|
||||||
empty_weight_g: float | None = Field(default=None, gt=0)
|
|
||||||
pao_months: int | None = Field(default=None, ge=1, le=60)
|
pao_months: int | None = Field(default=None, ge=1, le=60)
|
||||||
|
|
||||||
inci: list[str] = Field(default_factory=list)
|
inci: list[str] = Field(default_factory=list)
|
||||||
|
|
@ -129,7 +128,6 @@ class ProductBase(SQLModel):
|
||||||
needle_length_mm: float | None = Field(default=None, gt=0)
|
needle_length_mm: float | None = Field(default=None, gt=0)
|
||||||
|
|
||||||
personal_tolerance_notes: str | None = None
|
personal_tolerance_notes: str | None = None
|
||||||
personal_repurchase_intent: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -338,8 +336,6 @@ class Product(ProductBase, table=True):
|
||||||
ctx["needle_length_mm"] = self.needle_length_mm
|
ctx["needle_length_mm"] = self.needle_length_mm
|
||||||
if self.personal_tolerance_notes:
|
if self.personal_tolerance_notes:
|
||||||
ctx["personal_tolerance_notes"] = self.personal_tolerance_notes
|
ctx["personal_tolerance_notes"] = self.personal_tolerance_notes
|
||||||
if self.personal_repurchase_intent is not None:
|
|
||||||
ctx["personal_repurchase_intent"] = self.personal_repurchase_intent
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
opened_items = [
|
opened_items = [
|
||||||
|
|
@ -365,8 +361,7 @@ class ProductInventory(SQLModel, table=True):
|
||||||
opened_at: date | None = Field(default=None)
|
opened_at: date | None = Field(default=None)
|
||||||
finished_at: date | None = Field(default=None)
|
finished_at: date | None = Field(default=None)
|
||||||
expiry_date: date | None = Field(default=None)
|
expiry_date: date | None = Field(default=None)
|
||||||
current_weight_g: float | None = Field(default=None, gt=0)
|
remaining_level: RemainingLevel | None = None
|
||||||
last_weighed_at: date | None = Field(default=None)
|
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
|
|
||||||
created_at: datetime = Field(default_factory=utc_now, nullable=False)
|
created_at: datetime = Field(default_factory=utc_now, nullable=False)
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,17 @@ def test_update_inventory_opened(client, created_product):
|
||||||
|
|
||||||
r2 = client.patch(
|
r2 = client.patch(
|
||||||
f"/inventory/{inv_id}",
|
f"/inventory/{inv_id}",
|
||||||
json={"is_opened": True, "opened_at": "2026-01-15"},
|
json={
|
||||||
|
"is_opened": True,
|
||||||
|
"opened_at": "2026-01-15",
|
||||||
|
"remaining_level": "low",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert r2.status_code == 200
|
assert r2.status_code == 200
|
||||||
data = r2.json()
|
data = r2.json()
|
||||||
assert data["is_opened"] is True
|
assert data["is_opened"] is True
|
||||||
assert data["opened_at"] == "2026-01-15"
|
assert data["opened_at"] == "2026-01-15"
|
||||||
|
assert data["remaining_level"] == "low"
|
||||||
|
|
||||||
|
|
||||||
def test_update_inventory_not_found(client):
|
def test_update_inventory_not_found(client):
|
||||||
|
|
|
||||||
|
|
@ -187,11 +187,15 @@ def test_list_inventory_product_not_found(client):
|
||||||
|
|
||||||
def test_create_inventory(client, created_product):
|
def test_create_inventory(client, created_product):
|
||||||
pid = created_product["id"]
|
pid = created_product["id"]
|
||||||
r = client.post(f"/products/{pid}/inventory", json={"is_opened": False})
|
r = client.post(
|
||||||
|
f"/products/{pid}/inventory",
|
||||||
|
json={"is_opened": True, "remaining_level": "medium"},
|
||||||
|
)
|
||||||
assert r.status_code == 201
|
assert r.status_code == 201
|
||||||
data = r.json()
|
data = r.json()
|
||||||
assert data["product_id"] == pid
|
assert data["product_id"] == pid
|
||||||
assert data["is_opened"] is False
|
assert data["is_opened"] is True
|
||||||
|
assert data["remaining_level"] == "medium"
|
||||||
|
|
||||||
|
|
||||||
def test_create_inventory_product_not_found(client):
|
def test_create_inventory_product_not_found(client):
|
||||||
|
|
@ -204,11 +208,16 @@ def test_parse_text_accepts_numeric_strength_levels(client, monkeypatch):
|
||||||
|
|
||||||
class _FakeResponse:
|
class _FakeResponse:
|
||||||
text = (
|
text = (
|
||||||
'{"name":"Test Serum","actives":[{"name":"Niacinamide","percent":10,'
|
'{"name":"Test Serum","category":"serum","recommended_time":"both",'
|
||||||
|
'"leave_on":true,"actives":[{"name":"Niacinamide","percent":10,'
|
||||||
'"functions":["niacinamide"],"strength_level":2,"irritation_potential":1}]}'
|
'"functions":["niacinamide"],"strength_level":2,"irritation_potential":1}]}'
|
||||||
)
|
)
|
||||||
|
|
||||||
monkeypatch.setattr(products_api, "call_gemini", lambda **kwargs: _FakeResponse())
|
monkeypatch.setattr(
|
||||||
|
products_api,
|
||||||
|
"call_gemini",
|
||||||
|
lambda **kwargs: (_FakeResponse(), None),
|
||||||
|
)
|
||||||
|
|
||||||
r = client.post("/products/parse-text", json={"text": "dummy input"})
|
r = client.post("/products/parse-text", json={"text": "dummy input"})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ from innercontext.api.products import (
|
||||||
ProductSuggestion,
|
ProductSuggestion,
|
||||||
ShoppingSuggestionResponse,
|
ShoppingSuggestionResponse,
|
||||||
_build_shopping_context,
|
_build_shopping_context,
|
||||||
|
_compute_days_since_last_used,
|
||||||
|
_compute_replenishment_score,
|
||||||
_extract_requested_product_ids,
|
_extract_requested_product_ids,
|
||||||
build_product_details_tool_handler,
|
build_product_details_tool_handler,
|
||||||
)
|
)
|
||||||
|
|
@ -68,7 +70,12 @@ def test_build_shopping_context(session: Session):
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
# Add inventory
|
# Add inventory
|
||||||
inv = ProductInventory(id=uuid.uuid4(), product_id=p.id, is_opened=True)
|
inv = ProductInventory(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
product_id=p.id,
|
||||||
|
is_opened=True,
|
||||||
|
remaining_level="medium",
|
||||||
|
)
|
||||||
session.add(inv)
|
session.add(inv)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
@ -87,9 +94,89 @@ def test_build_shopping_context(session: Session):
|
||||||
assert "Soothing Serum" in ctx
|
assert "Soothing Serum" in ctx
|
||||||
assert f"id={p.id}" in ctx
|
assert f"id={p.id}" in ctx
|
||||||
assert "BrandX" in ctx
|
assert "BrandX" in ctx
|
||||||
assert "targets: ['redness']" in ctx
|
assert "targets=['redness']" in ctx
|
||||||
assert "actives: ['Centella']" in ctx
|
assert "actives=['Centella']" in ctx
|
||||||
assert "effects: {'soothing': 4}" in ctx
|
assert "effects={'soothing': 4}" in ctx
|
||||||
|
assert "stock_state=monitor" in ctx
|
||||||
|
assert "opened_count=1" in ctx
|
||||||
|
assert "sealed_backup_count=0" in ctx
|
||||||
|
assert "lowest_remaining_level=medium" in ctx
|
||||||
|
assert "replenishment_score=30" in ctx
|
||||||
|
assert "replenishment_priority_hint=low" in ctx
|
||||||
|
assert "repurchase_candidate=true" in ctx
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_shopping_context_flags_replenishment_signal(session: Session):
|
||||||
|
product = Product(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
short_id=str(uuid.uuid4())[:8],
|
||||||
|
name="Barrier Cleanser",
|
||||||
|
brand="BrandY",
|
||||||
|
category="cleanser",
|
||||||
|
recommended_time="both",
|
||||||
|
leave_on=False,
|
||||||
|
product_effect_profile={},
|
||||||
|
)
|
||||||
|
session.add(product)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
ProductInventory(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
product_id=product.id,
|
||||||
|
is_opened=True,
|
||||||
|
remaining_level="nearly_empty",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
ctx = _build_shopping_context(session, reference_date=date.today())
|
||||||
|
assert "lowest_remaining_level=nearly_empty" in ctx
|
||||||
|
assert "stock_state=urgent" in ctx
|
||||||
|
assert "replenishment_priority_hint=high" in ctx
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_replenishment_score_prefers_recent_staples_without_backup():
|
||||||
|
result = _compute_replenishment_score(
|
||||||
|
has_stock=True,
|
||||||
|
sealed_backup_count=0,
|
||||||
|
lowest_remaining_level="low",
|
||||||
|
days_since_last_used=2,
|
||||||
|
category=ProductCategory.CLEANSER,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["replenishment_score"] == 95
|
||||||
|
assert result["replenishment_priority_hint"] == "high"
|
||||||
|
assert result["repurchase_candidate"] is True
|
||||||
|
assert result["replenishment_reason_codes"] == [
|
||||||
|
"low_opened",
|
||||||
|
"recently_used",
|
||||||
|
"staple_category",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_replenishment_score_downranks_sealed_backup_and_stale_usage():
|
||||||
|
result = _compute_replenishment_score(
|
||||||
|
has_stock=True,
|
||||||
|
sealed_backup_count=1,
|
||||||
|
lowest_remaining_level="nearly_empty",
|
||||||
|
days_since_last_used=70,
|
||||||
|
category=ProductCategory.EXFOLIANT,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["replenishment_score"] == 0
|
||||||
|
assert result["replenishment_priority_hint"] == "none"
|
||||||
|
assert result["repurchase_candidate"] is False
|
||||||
|
assert result["replenishment_reason_codes"] == [
|
||||||
|
"has_sealed_backup",
|
||||||
|
"stale_usage",
|
||||||
|
"occasional_category",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_days_since_last_used_returns_none_without_usage():
|
||||||
|
assert _compute_days_since_last_used(None, date(2026, 3, 9)) is None
|
||||||
|
assert _compute_days_since_last_used(date(2026, 3, 7), date(2026, 3, 9)) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_shopping(client, session):
|
def test_suggest_shopping(client, session):
|
||||||
|
|
|
||||||
|
|
@ -95,8 +95,11 @@
|
||||||
"inventory_openedDate": "Opened date",
|
"inventory_openedDate": "Opened date",
|
||||||
"inventory_finishedDate": "Finished date",
|
"inventory_finishedDate": "Finished date",
|
||||||
"inventory_expiryDate": "Expiry date",
|
"inventory_expiryDate": "Expiry date",
|
||||||
"inventory_currentWeight": "Current weight (g)",
|
"inventory_remainingLevel": "Remaining product level",
|
||||||
"inventory_lastWeighed": "Last weighed",
|
"inventory_remainingHigh": "high",
|
||||||
|
"inventory_remainingMedium": "medium",
|
||||||
|
"inventory_remainingLow": "low",
|
||||||
|
"inventory_remainingNearlyEmpty": "nearly empty",
|
||||||
"inventory_notes": "Notes",
|
"inventory_notes": "Notes",
|
||||||
"inventory_badgeOpen": "Open",
|
"inventory_badgeOpen": "Open",
|
||||||
"inventory_badgeSealed": "Sealed",
|
"inventory_badgeSealed": "Sealed",
|
||||||
|
|
@ -104,8 +107,6 @@
|
||||||
"inventory_exp": "Exp:",
|
"inventory_exp": "Exp:",
|
||||||
"inventory_opened": "Opened:",
|
"inventory_opened": "Opened:",
|
||||||
"inventory_finished": "Finished:",
|
"inventory_finished": "Finished:",
|
||||||
"inventory_remaining": "g remaining",
|
|
||||||
"inventory_weighed": "Weighed:",
|
|
||||||
"inventory_confirmDelete": "Delete this package?",
|
"inventory_confirmDelete": "Delete this package?",
|
||||||
|
|
||||||
"routines_title": "Routines",
|
"routines_title": "Routines",
|
||||||
|
|
@ -514,7 +515,6 @@
|
||||||
"productForm_isTool": "Is tool (e.g. dermaroller)",
|
"productForm_isTool": "Is tool (e.g. dermaroller)",
|
||||||
"productForm_needleLengthMm": "Needle length (mm, tools only)",
|
"productForm_needleLengthMm": "Needle length (mm, tools only)",
|
||||||
"productForm_personalNotes": "Personal notes",
|
"productForm_personalNotes": "Personal notes",
|
||||||
"productForm_repurchaseIntent": "Repurchase intent",
|
|
||||||
"productForm_toleranceNotes": "Tolerance notes",
|
"productForm_toleranceNotes": "Tolerance notes",
|
||||||
"productForm_toleranceNotesPlaceholder": "e.g. Causes mild stinging, fine after 2 weeks",
|
"productForm_toleranceNotesPlaceholder": "e.g. Causes mild stinging, fine after 2 weeks",
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,8 +97,11 @@
|
||||||
"inventory_openedDate": "Data otwarcia",
|
"inventory_openedDate": "Data otwarcia",
|
||||||
"inventory_finishedDate": "Data skończenia",
|
"inventory_finishedDate": "Data skończenia",
|
||||||
"inventory_expiryDate": "Data ważności",
|
"inventory_expiryDate": "Data ważności",
|
||||||
"inventory_currentWeight": "Aktualna waga (g)",
|
"inventory_remainingLevel": "Poziom pozostałego produktu:",
|
||||||
"inventory_lastWeighed": "Ostatnie ważenie",
|
"inventory_remainingHigh": "dużo",
|
||||||
|
"inventory_remainingMedium": "średnio",
|
||||||
|
"inventory_remainingLow": "mało",
|
||||||
|
"inventory_remainingNearlyEmpty": "prawie puste",
|
||||||
"inventory_notes": "Notatki",
|
"inventory_notes": "Notatki",
|
||||||
"inventory_badgeOpen": "Otwarte",
|
"inventory_badgeOpen": "Otwarte",
|
||||||
"inventory_badgeSealed": "Zamknięte",
|
"inventory_badgeSealed": "Zamknięte",
|
||||||
|
|
@ -106,8 +109,6 @@
|
||||||
"inventory_exp": "Wazność:",
|
"inventory_exp": "Wazność:",
|
||||||
"inventory_opened": "Otwarto:",
|
"inventory_opened": "Otwarto:",
|
||||||
"inventory_finished": "Skończono:",
|
"inventory_finished": "Skończono:",
|
||||||
"inventory_remaining": "g pozostało",
|
|
||||||
"inventory_weighed": "Ważono:",
|
|
||||||
"inventory_confirmDelete": "Usunąć to opakowanie?",
|
"inventory_confirmDelete": "Usunąć to opakowanie?",
|
||||||
|
|
||||||
"routines_title": "Rutyny",
|
"routines_title": "Rutyny",
|
||||||
|
|
@ -528,7 +529,6 @@
|
||||||
"productForm_isTool": "To narzędzie (np. dermaroller)",
|
"productForm_isTool": "To narzędzie (np. dermaroller)",
|
||||||
"productForm_needleLengthMm": "Długość igły (mm, tylko narzędzia)",
|
"productForm_needleLengthMm": "Długość igły (mm, tylko narzędzia)",
|
||||||
"productForm_personalNotes": "Notatki osobiste",
|
"productForm_personalNotes": "Notatki osobiste",
|
||||||
"productForm_repurchaseIntent": "Zamiar ponownego zakupu",
|
|
||||||
"productForm_toleranceNotes": "Notatki o tolerancji",
|
"productForm_toleranceNotes": "Notatki o tolerancji",
|
||||||
"productForm_toleranceNotesPlaceholder": "np. Lekkie pieczenie, ustępuje po 2 tygodniach",
|
"productForm_toleranceNotesPlaceholder": "np. Lekkie pieczenie, ustępuje po 2 tygodniach",
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -135,8 +135,6 @@ export interface ProductParseResponse {
|
||||||
price_amount?: number;
|
price_amount?: number;
|
||||||
price_currency?: string;
|
price_currency?: string;
|
||||||
size_ml?: number;
|
size_ml?: number;
|
||||||
full_weight_g?: number;
|
|
||||||
empty_weight_g?: number;
|
|
||||||
pao_months?: number;
|
pao_months?: number;
|
||||||
inci?: string[];
|
inci?: string[];
|
||||||
actives?: ActiveIngredient[];
|
actives?: ActiveIngredient[];
|
||||||
|
|
|
||||||
|
|
@ -165,8 +165,6 @@
|
||||||
let sku = $state(untrack(() => product?.sku ?? ''));
|
let sku = $state(untrack(() => product?.sku ?? ''));
|
||||||
let barcode = $state(untrack(() => product?.barcode ?? ''));
|
let barcode = $state(untrack(() => product?.barcode ?? ''));
|
||||||
let sizeMl = $state(untrack(() => (product?.size_ml != null ? String(product.size_ml) : '')));
|
let sizeMl = $state(untrack(() => (product?.size_ml != null ? String(product.size_ml) : '')));
|
||||||
let fullWeightG = $state(untrack(() => (product?.full_weight_g != null ? String(product.full_weight_g) : '')));
|
|
||||||
let emptyWeightG = $state(untrack(() => (product?.empty_weight_g != null ? String(product.empty_weight_g) : '')));
|
|
||||||
let paoMonths = $state(untrack(() => (product?.pao_months != null ? String(product.pao_months) : '')));
|
let paoMonths = $state(untrack(() => (product?.pao_months != null ? String(product.pao_months) : '')));
|
||||||
let phMin = $state(untrack(() => (product?.ph_min != null ? String(product.ph_min) : '')));
|
let phMin = $state(untrack(() => (product?.ph_min != null ? String(product.ph_min) : '')));
|
||||||
let phMax = $state(untrack(() => (product?.ph_max != null ? String(product.ph_max) : '')));
|
let phMax = $state(untrack(() => (product?.ph_max != null ? String(product.ph_max) : '')));
|
||||||
|
|
@ -221,8 +219,6 @@
|
||||||
if (r.price_currency) priceCurrency = r.price_currency;
|
if (r.price_currency) priceCurrency = r.price_currency;
|
||||||
if (r.leave_on != null) leaveOn = String(r.leave_on);
|
if (r.leave_on != null) leaveOn = String(r.leave_on);
|
||||||
if (r.size_ml != null) sizeMl = String(r.size_ml);
|
if (r.size_ml != null) sizeMl = String(r.size_ml);
|
||||||
if (r.full_weight_g != null) fullWeightG = String(r.full_weight_g);
|
|
||||||
if (r.empty_weight_g != null) emptyWeightG = String(r.empty_weight_g);
|
|
||||||
if (r.pao_months != null) paoMonths = String(r.pao_months);
|
if (r.pao_months != null) paoMonths = String(r.pao_months);
|
||||||
if (r.ph_min != null) phMin = String(r.ph_min);
|
if (r.ph_min != null) phMin = String(r.ph_min);
|
||||||
if (r.ph_max != null) phMax = String(r.ph_max);
|
if (r.ph_max != null) phMax = String(r.ph_max);
|
||||||
|
|
@ -281,9 +277,6 @@
|
||||||
let pregnancySafe = $state(
|
let pregnancySafe = $state(
|
||||||
untrack(() => (product?.pregnancy_safe != null ? String(product.pregnancy_safe) : ''))
|
untrack(() => (product?.pregnancy_safe != null ? String(product.pregnancy_safe) : ''))
|
||||||
);
|
);
|
||||||
let personalRepurchaseIntent = $state(
|
|
||||||
untrack(() => (product?.personal_repurchase_intent != null ? String(product.personal_repurchase_intent) : ''))
|
|
||||||
);
|
|
||||||
|
|
||||||
// context rules tristate
|
// context rules tristate
|
||||||
const cr = untrack(() => product?.context_rules);
|
const cr = untrack(() => product?.context_rules);
|
||||||
|
|
@ -403,8 +396,6 @@
|
||||||
sku,
|
sku,
|
||||||
barcode,
|
barcode,
|
||||||
sizeMl,
|
sizeMl,
|
||||||
fullWeightG,
|
|
||||||
emptyWeightG,
|
|
||||||
paoMonths,
|
paoMonths,
|
||||||
phMin,
|
phMin,
|
||||||
phMax,
|
phMax,
|
||||||
|
|
@ -428,7 +419,6 @@
|
||||||
essentialOilsFree,
|
essentialOilsFree,
|
||||||
alcoholDenatFree,
|
alcoholDenatFree,
|
||||||
pregnancySafe,
|
pregnancySafe,
|
||||||
personalRepurchaseIntent,
|
|
||||||
ctxAfterShaving,
|
ctxAfterShaving,
|
||||||
ctxAfterAcids,
|
ctxAfterAcids,
|
||||||
ctxAfterRetinoids,
|
ctxAfterRetinoids,
|
||||||
|
|
@ -723,8 +713,6 @@
|
||||||
bind:priceAmount
|
bind:priceAmount
|
||||||
bind:priceCurrency
|
bind:priceCurrency
|
||||||
bind:sizeMl
|
bind:sizeMl
|
||||||
bind:fullWeightG
|
|
||||||
bind:emptyWeightG
|
|
||||||
bind:paoMonths
|
bind:paoMonths
|
||||||
bind:phMin
|
bind:phMin
|
||||||
bind:phMax
|
bind:phMax
|
||||||
|
|
@ -743,10 +731,7 @@
|
||||||
{@const NotesSection = mod.default}
|
{@const NotesSection = mod.default}
|
||||||
<NotesSection
|
<NotesSection
|
||||||
visible={editSection === 'notes'}
|
visible={editSection === 'notes'}
|
||||||
{selectClass}
|
|
||||||
{textareaClass}
|
{textareaClass}
|
||||||
{tristate}
|
|
||||||
bind:personalRepurchaseIntent
|
|
||||||
bind:personalToleranceNotes
|
bind:personalToleranceNotes
|
||||||
/>
|
/>
|
||||||
{/await}
|
{/await}
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,6 @@
|
||||||
priceAmount = $bindable(''),
|
priceAmount = $bindable(''),
|
||||||
priceCurrency = $bindable('PLN'),
|
priceCurrency = $bindable('PLN'),
|
||||||
sizeMl = $bindable(''),
|
sizeMl = $bindable(''),
|
||||||
fullWeightG = $bindable(''),
|
|
||||||
emptyWeightG = $bindable(''),
|
|
||||||
paoMonths = $bindable(''),
|
paoMonths = $bindable(''),
|
||||||
phMin = $bindable(''),
|
phMin = $bindable(''),
|
||||||
phMax = $bindable(''),
|
phMax = $bindable(''),
|
||||||
|
|
@ -27,8 +25,6 @@
|
||||||
priceAmount?: string;
|
priceAmount?: string;
|
||||||
priceCurrency?: string;
|
priceCurrency?: string;
|
||||||
sizeMl?: string;
|
sizeMl?: string;
|
||||||
fullWeightG?: string;
|
|
||||||
emptyWeightG?: string;
|
|
||||||
paoMonths?: string;
|
paoMonths?: string;
|
||||||
phMin?: string;
|
phMin?: string;
|
||||||
phMax?: string;
|
phMax?: string;
|
||||||
|
|
@ -63,16 +59,6 @@
|
||||||
<Input id="size_ml" name="size_ml" type="number" min="0" step="0.1" placeholder={m["productForm_sizePlaceholder"]()} bind:value={sizeMl} />
|
<Input id="size_ml" name="size_ml" type="number" min="0" step="0.1" placeholder={m["productForm_sizePlaceholder"]()} bind:value={sizeMl} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="full_weight_g">{m["productForm_fullWeightG"]()}</Label>
|
|
||||||
<Input id="full_weight_g" name="full_weight_g" type="number" min="0" step="0.1" placeholder={m["productForm_fullWeightPlaceholder"]()} bind:value={fullWeightG} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="empty_weight_g">{m["productForm_emptyWeightG"]()}</Label>
|
|
||||||
<Input id="empty_weight_g" name="empty_weight_g" type="number" min="0" step="0.1" placeholder={m["productForm_emptyWeightPlaceholder"]()} bind:value={emptyWeightG} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="pao_months">{m["productForm_paoMonths"]()}</Label>
|
<Label for="pao_months">{m["productForm_paoMonths"]()}</Label>
|
||||||
<Input id="pao_months" name="pao_months" type="number" min="1" max="60" placeholder={m["productForm_paoPlaceholder"]()} bind:value={paoMonths} />
|
<Input id="pao_months" name="pao_months" type="number" min="1" max="60" placeholder={m["productForm_paoPlaceholder"]()} bind:value={paoMonths} />
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,13 @@
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
|
||||||
type TriOption = { value: string; label: string };
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
visible = false,
|
visible = false,
|
||||||
selectClass,
|
|
||||||
textareaClass,
|
textareaClass,
|
||||||
tristate,
|
|
||||||
personalRepurchaseIntent = $bindable(''),
|
|
||||||
personalToleranceNotes = $bindable('')
|
personalToleranceNotes = $bindable('')
|
||||||
}: {
|
}: {
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
selectClass: string;
|
|
||||||
textareaClass: string;
|
textareaClass: string;
|
||||||
tristate: TriOption[];
|
|
||||||
personalRepurchaseIntent?: string;
|
|
||||||
personalToleranceNotes?: string;
|
personalToleranceNotes?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -25,15 +17,6 @@
|
||||||
<Card class={visible ? '' : 'hidden'}>
|
<Card class={visible ? '' : 'hidden'}>
|
||||||
<CardHeader><CardTitle>{m["productForm_personalNotes"]()}</CardTitle></CardHeader>
|
<CardHeader><CardTitle>{m["productForm_personalNotes"]()}</CardTitle></CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="repurchase_intent_select">{m["productForm_repurchaseIntent"]()}</Label>
|
|
||||||
<select id="repurchase_intent_select" name="personal_repurchase_intent" class={selectClass} bind:value={personalRepurchaseIntent}>
|
|
||||||
{#each tristate as opt (opt.value)}
|
|
||||||
<option value={opt.value}>{opt.label}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="personal_tolerance_notes">{m["productForm_toleranceNotes"]()}</Label>
|
<Label for="personal_tolerance_notes">{m["productForm_toleranceNotes"]()}</Label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export type OverallSkinState = "excellent" | "good" | "fair" | "poor";
|
||||||
export type PartOfDay = "am" | "pm";
|
export type PartOfDay = "am" | "pm";
|
||||||
export type PriceTier = "budget" | "mid" | "premium" | "luxury";
|
export type PriceTier = "budget" | "mid" | "premium" | "luxury";
|
||||||
export type PriceTierSource = "category" | "fallback" | "insufficient_data";
|
export type PriceTierSource = "category" | "fallback" | "insufficient_data";
|
||||||
|
export type RemainingLevel = "high" | "medium" | "low" | "nearly_empty";
|
||||||
export type ProductCategory =
|
export type ProductCategory =
|
||||||
| "cleanser"
|
| "cleanser"
|
||||||
| "toner"
|
| "toner"
|
||||||
|
|
@ -129,8 +130,7 @@ export interface ProductInventory {
|
||||||
opened_at?: string;
|
opened_at?: string;
|
||||||
finished_at?: string;
|
finished_at?: string;
|
||||||
expiry_date?: string;
|
expiry_date?: string;
|
||||||
current_weight_g?: number;
|
remaining_level?: RemainingLevel;
|
||||||
last_weighed_at?: string;
|
|
||||||
notes?: string;
|
notes?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
product?: Product;
|
product?: Product;
|
||||||
|
|
@ -155,8 +155,6 @@ export interface Product {
|
||||||
price_per_use_pln?: number;
|
price_per_use_pln?: number;
|
||||||
price_tier_source?: PriceTierSource;
|
price_tier_source?: PriceTierSource;
|
||||||
size_ml?: number;
|
size_ml?: number;
|
||||||
full_weight_g?: number;
|
|
||||||
empty_weight_g?: number;
|
|
||||||
pao_months?: number;
|
pao_months?: number;
|
||||||
inci: string[];
|
inci: string[];
|
||||||
actives?: ActiveIngredient[];
|
actives?: ActiveIngredient[];
|
||||||
|
|
@ -176,7 +174,6 @@ export interface Product {
|
||||||
is_tool: boolean;
|
is_tool: boolean;
|
||||||
needle_length_mm?: number;
|
needle_length_mm?: number;
|
||||||
personal_tolerance_notes?: string;
|
personal_tolerance_notes?: string;
|
||||||
personal_repurchase_intent?: boolean;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
inventory: ProductInventory[];
|
inventory: ProductInventory[];
|
||||||
|
|
|
||||||
|
|
@ -127,8 +127,6 @@ export const actions: Actions = {
|
||||||
|
|
||||||
// Optional numbers
|
// Optional numbers
|
||||||
body.size_ml = parseOptionalFloat(form.get('size_ml') as string | null) ?? null;
|
body.size_ml = parseOptionalFloat(form.get('size_ml') as string | null) ?? null;
|
||||||
body.full_weight_g = parseOptionalFloat(form.get('full_weight_g') as string | null) ?? null;
|
|
||||||
body.empty_weight_g = parseOptionalFloat(form.get('empty_weight_g') as string | null) ?? null;
|
|
||||||
body.pao_months = parseOptionalInt(form.get('pao_months') as string | null) ?? null;
|
body.pao_months = parseOptionalInt(form.get('pao_months') as string | null) ?? null;
|
||||||
body.ph_min = parseOptionalFloat(form.get('ph_min') as string | null) ?? null;
|
body.ph_min = parseOptionalFloat(form.get('ph_min') as string | null) ?? null;
|
||||||
body.ph_max = parseOptionalFloat(form.get('ph_max') as string | null) ?? null;
|
body.ph_max = parseOptionalFloat(form.get('ph_max') as string | null) ?? null;
|
||||||
|
|
@ -144,7 +142,7 @@ export const actions: Actions = {
|
||||||
body.is_tool = form.get('is_tool') === 'true';
|
body.is_tool = form.get('is_tool') === 'true';
|
||||||
|
|
||||||
// Nullable booleans
|
// Nullable booleans
|
||||||
for (const field of ['fragrance_free', 'essential_oils_free', 'alcohol_denat_free', 'pregnancy_safe', 'personal_repurchase_intent']) {
|
for (const field of ['fragrance_free', 'essential_oils_free', 'alcohol_denat_free', 'pregnancy_safe']) {
|
||||||
body[field] = parseTristate(form.get(field) as string | null) ?? null;
|
body[field] = parseTristate(form.get(field) as string | null) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,10 +190,8 @@ export const actions: Actions = {
|
||||||
if (finishedAt) body.finished_at = finishedAt;
|
if (finishedAt) body.finished_at = finishedAt;
|
||||||
const expiry = form.get('expiry_date');
|
const expiry = form.get('expiry_date');
|
||||||
if (expiry) body.expiry_date = expiry;
|
if (expiry) body.expiry_date = expiry;
|
||||||
const weight = form.get('current_weight_g');
|
const remainingLevel = form.get('remaining_level');
|
||||||
if (weight) body.current_weight_g = Number(weight);
|
if (remainingLevel) body.remaining_level = remainingLevel;
|
||||||
const lastWeighed = form.get('last_weighed_at');
|
|
||||||
if (lastWeighed) body.last_weighed_at = lastWeighed;
|
|
||||||
const notes = form.get('notes');
|
const notes = form.get('notes');
|
||||||
if (notes) body.notes = notes;
|
if (notes) body.notes = notes;
|
||||||
try {
|
try {
|
||||||
|
|
@ -219,10 +215,8 @@ export const actions: Actions = {
|
||||||
body.finished_at = finishedAt || null;
|
body.finished_at = finishedAt || null;
|
||||||
const expiry = form.get('expiry_date');
|
const expiry = form.get('expiry_date');
|
||||||
body.expiry_date = expiry || null;
|
body.expiry_date = expiry || null;
|
||||||
const weight = form.get('current_weight_g');
|
const remainingLevel = form.get('remaining_level');
|
||||||
body.current_weight_g = weight ? Number(weight) : null;
|
body.remaining_level = remainingLevel || null;
|
||||||
const lastWeighed = form.get('last_weighed_at');
|
|
||||||
body.last_weighed_at = lastWeighed || null;
|
|
||||||
const notes = form.get('notes');
|
const notes = form.get('notes');
|
||||||
body.notes = notes || null;
|
body.notes = notes || null;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
|
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
|
@ -15,8 +16,16 @@
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
let { product } = $derived(data);
|
let { product } = $derived(data);
|
||||||
|
|
||||||
|
const remainingLevels = ['high', 'medium', 'low', 'nearly_empty'] as const;
|
||||||
|
const remainingLevelOptions = remainingLevels.map((level) => ({
|
||||||
|
value: level,
|
||||||
|
label: remainingLevelLabel(level)
|
||||||
|
}));
|
||||||
|
|
||||||
let showInventoryForm = $state(false);
|
let showInventoryForm = $state(false);
|
||||||
|
let addInventoryOpened = $state(false);
|
||||||
let editingInventoryId = $state<string | null>(null);
|
let editingInventoryId = $state<string | null>(null);
|
||||||
|
let editingInventoryOpened = $state<Record<string, boolean>>({});
|
||||||
let activeTab = $state<'inventory' | 'edit'>('edit');
|
let activeTab = $state<'inventory' | 'edit'>('edit');
|
||||||
let isEditDirty = $state(false);
|
let isEditDirty = $state(false);
|
||||||
let editSaveVersion = $state(0);
|
let editSaveVersion = $state(0);
|
||||||
|
|
@ -60,6 +69,14 @@
|
||||||
if (value === 'luxury') return m['productForm_priceLuxury']();
|
if (value === 'luxury') return m['productForm_priceLuxury']();
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function remainingLevelLabel(value?: string): string {
|
||||||
|
if (value === 'high') return m['inventory_remainingHigh']();
|
||||||
|
if (value === 'medium') return m['inventory_remainingMedium']();
|
||||||
|
if (value === 'low') return m['inventory_remainingLow']();
|
||||||
|
if (value === 'nearly_empty') return m['inventory_remainingNearlyEmpty']();
|
||||||
|
return '';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
|
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
|
||||||
|
|
@ -150,9 +167,10 @@
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form method="POST" action="?/addInventory" use:enhance class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<form method="POST" action="?/addInventory" use:enhance class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div class="sm:col-span-2 flex items-center gap-2">
|
<div class="sm:col-span-2 flex items-center gap-2">
|
||||||
<input type="checkbox" id="add_is_opened" name="is_opened" value="true" class="h-4 w-4" />
|
<input type="checkbox" id="add_is_opened" name="is_opened" value="true" class="h-4 w-4" bind:checked={addInventoryOpened} />
|
||||||
<Label for="add_is_opened">{m["inventory_alreadyOpened"]()}</Label>
|
<Label for="add_is_opened">{m["inventory_alreadyOpened"]()}</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label for="add_opened_at">{m["inventory_openedDate"]()}</Label>
|
<Label for="add_opened_at">{m["inventory_openedDate"]()}</Label>
|
||||||
<Input id="add_opened_at" name="opened_at" type="date" />
|
<Input id="add_opened_at" name="opened_at" type="date" />
|
||||||
|
|
@ -165,14 +183,15 @@
|
||||||
<Label for="add_expiry_date">{m["inventory_expiryDate"]()}</Label>
|
<Label for="add_expiry_date">{m["inventory_expiryDate"]()}</Label>
|
||||||
<Input id="add_expiry_date" name="expiry_date" type="date" />
|
<Input id="add_expiry_date" name="expiry_date" type="date" />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
{#if addInventoryOpened}
|
||||||
<Label for="add_current_weight_g">{m["inventory_currentWeight"]()}</Label>
|
<SimpleSelect
|
||||||
<Input id="add_current_weight_g" name="current_weight_g" type="number" min="0" />
|
id="add_remaining_level"
|
||||||
</div>
|
name="remaining_level"
|
||||||
<div class="space-y-1">
|
label={m["inventory_remainingLevel"]()}
|
||||||
<Label for="add_last_weighed_at">{m["inventory_lastWeighed"]()}</Label>
|
options={remainingLevelOptions}
|
||||||
<Input id="add_last_weighed_at" name="last_weighed_at" type="date" />
|
placeholder={m.common_unknown()}
|
||||||
</div>
|
/>
|
||||||
|
{/if}
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label for="add_notes">{m.inventory_notes()}</Label>
|
<Label for="add_notes">{m.inventory_notes()}</Label>
|
||||||
<Input id="add_notes" name="notes" />
|
<Input id="add_notes" name="notes" />
|
||||||
|
|
@ -206,11 +225,8 @@
|
||||||
{#if pkg.finished_at}
|
{#if pkg.finished_at}
|
||||||
<span class="text-muted-foreground">{m.inventory_finished()} {pkg.finished_at.slice(0, 10)}</span>
|
<span class="text-muted-foreground">{m.inventory_finished()} {pkg.finished_at.slice(0, 10)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if pkg.current_weight_g}
|
{#if pkg.is_opened && pkg.remaining_level}
|
||||||
<span class="text-muted-foreground">{pkg.current_weight_g}g {m.inventory_remaining()}</span>
|
<span class="text-muted-foreground">{m["inventory_remainingLevel"]()} {remainingLevelLabel(pkg.remaining_level)}</span>
|
||||||
{/if}
|
|
||||||
{#if pkg.last_weighed_at}
|
|
||||||
<span class="text-muted-foreground">{m.inventory_weighed()} {pkg.last_weighed_at.slice(0, 10)}</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if pkg.notes}
|
{#if pkg.notes}
|
||||||
<span class="text-muted-foreground">{pkg.notes}</span>
|
<span class="text-muted-foreground">{pkg.notes}</span>
|
||||||
|
|
@ -220,7 +236,14 @@
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={() => (editingInventoryId = editingInventoryId === pkg.id ? null : pkg.id)}
|
onclick={() => {
|
||||||
|
if (editingInventoryId === pkg.id) {
|
||||||
|
editingInventoryId = null;
|
||||||
|
} else {
|
||||||
|
editingInventoryId = pkg.id;
|
||||||
|
editingInventoryOpened = { ...editingInventoryOpened, [pkg.id]: pkg.is_opened };
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{editingInventoryId === pkg.id ? m.common_cancel() : m.common_edit()}
|
{editingInventoryId === pkg.id ? m.common_cancel() : m.common_edit()}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -256,7 +279,11 @@
|
||||||
id="edit_is_opened_{pkg.id}"
|
id="edit_is_opened_{pkg.id}"
|
||||||
name="is_opened"
|
name="is_opened"
|
||||||
value="true"
|
value="true"
|
||||||
checked={pkg.is_opened}
|
checked={editingInventoryOpened[pkg.id] ?? pkg.is_opened}
|
||||||
|
onchange={(e) => {
|
||||||
|
const target = e.currentTarget as HTMLInputElement;
|
||||||
|
editingInventoryOpened = { ...editingInventoryOpened, [pkg.id]: target.checked };
|
||||||
|
}}
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
<Label for="edit_is_opened_{pkg.id}">{m["inventory_alreadyOpened"]()}</Label>
|
<Label for="edit_is_opened_{pkg.id}">{m["inventory_alreadyOpened"]()}</Label>
|
||||||
|
|
@ -273,14 +300,16 @@
|
||||||
<Label for="edit_expiry_{pkg.id}">{m["inventory_expiryDate"]()}</Label>
|
<Label for="edit_expiry_{pkg.id}">{m["inventory_expiryDate"]()}</Label>
|
||||||
<Input id="edit_expiry_{pkg.id}" name="expiry_date" type="date" value={pkg.expiry_date?.slice(0, 10) ?? ''} />
|
<Input id="edit_expiry_{pkg.id}" name="expiry_date" type="date" value={pkg.expiry_date?.slice(0, 10) ?? ''} />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
{#if editingInventoryOpened[pkg.id] ?? pkg.is_opened}
|
||||||
<Label for="edit_weight_{pkg.id}">{m["inventory_currentWeight"]()}</Label>
|
<SimpleSelect
|
||||||
<Input id="edit_weight_{pkg.id}" name="current_weight_g" type="number" min="0" value={pkg.current_weight_g ?? ''} />
|
id={`edit_remaining_level_${pkg.id}`}
|
||||||
</div>
|
name="remaining_level"
|
||||||
<div class="space-y-1">
|
label={m["inventory_remainingLevel"]()}
|
||||||
<Label for="edit_last_weighed_{pkg.id}">{m["inventory_lastWeighed"]()}</Label>
|
options={remainingLevelOptions}
|
||||||
<Input id="edit_last_weighed_{pkg.id}" name="last_weighed_at" type="date" value={pkg.last_weighed_at?.slice(0, 10) ?? ''} />
|
placeholder={m.common_unknown()}
|
||||||
</div>
|
value={pkg.remaining_level ?? ''}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label for="edit_notes_{pkg.id}">{m.inventory_notes()}</Label>
|
<Label for="edit_notes_{pkg.id}">{m.inventory_notes()}</Label>
|
||||||
<Input id="edit_notes_{pkg.id}" name="notes" value={pkg.notes ?? ''} />
|
<Input id="edit_notes_{pkg.id}" name="notes" value={pkg.notes ?? ''} />
|
||||||
|
|
|
||||||
|
|
@ -118,12 +118,6 @@ export const actions: Actions = {
|
||||||
const size_ml = parseOptionalFloat(form.get('size_ml') as string | null);
|
const size_ml = parseOptionalFloat(form.get('size_ml') as string | null);
|
||||||
if (size_ml !== undefined) payload.size_ml = size_ml;
|
if (size_ml !== undefined) payload.size_ml = size_ml;
|
||||||
|
|
||||||
const full_weight_g = parseOptionalFloat(form.get('full_weight_g') as string | null);
|
|
||||||
if (full_weight_g !== undefined) payload.full_weight_g = full_weight_g;
|
|
||||||
|
|
||||||
const empty_weight_g = parseOptionalFloat(form.get('empty_weight_g') as string | null);
|
|
||||||
if (empty_weight_g !== undefined) payload.empty_weight_g = empty_weight_g;
|
|
||||||
|
|
||||||
const pao_months = parseOptionalInt(form.get('pao_months') as string | null);
|
const pao_months = parseOptionalInt(form.get('pao_months') as string | null);
|
||||||
if (pao_months !== undefined) payload.pao_months = pao_months;
|
if (pao_months !== undefined) payload.pao_months = pao_months;
|
||||||
|
|
||||||
|
|
@ -149,7 +143,7 @@ export const actions: Actions = {
|
||||||
payload.is_tool = form.get('is_tool') === 'true';
|
payload.is_tool = form.get('is_tool') === 'true';
|
||||||
|
|
||||||
// Nullable booleans
|
// Nullable booleans
|
||||||
for (const field of ['fragrance_free', 'essential_oils_free', 'alcohol_denat_free', 'pregnancy_safe', 'personal_repurchase_intent']) {
|
for (const field of ['fragrance_free', 'essential_oils_free', 'alcohol_denat_free', 'pregnancy_safe']) {
|
||||||
const v = parseTristate(form.get(field) as string | null);
|
const v = parseTristate(form.get(field) as string | null);
|
||||||
if (v !== undefined) payload[field] = v;
|
if (v !== undefined) payload[field] = v;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue