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 logging
|
||||
from datetime import date
|
||||
from typing import Any, Literal, Optional
|
||||
from typing import Any, Literal, Optional, cast
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
|
@ -48,6 +48,7 @@ from innercontext.models.enums import (
|
|||
AbsorptionSpeed,
|
||||
DayTime,
|
||||
PriceTier,
|
||||
RemainingLevel,
|
||||
SkinType,
|
||||
TextureType,
|
||||
)
|
||||
|
|
@ -128,8 +129,6 @@ class ProductUpdate(SQLModel):
|
|||
price_amount: Optional[float] = None
|
||||
price_currency: Optional[str] = None
|
||||
size_ml: Optional[float] = None
|
||||
full_weight_g: Optional[float] = None
|
||||
empty_weight_g: Optional[float] = None
|
||||
pao_months: Optional[int] = None
|
||||
|
||||
inci: Optional[list[str]] = None
|
||||
|
|
@ -159,7 +158,6 @@ class ProductUpdate(SQLModel):
|
|||
needle_length_mm: Optional[float] = None
|
||||
|
||||
personal_tolerance_notes: Optional[str] = None
|
||||
personal_repurchase_intent: Optional[bool] = None
|
||||
|
||||
|
||||
class ProductParseRequest(SQLModel):
|
||||
|
|
@ -181,8 +179,6 @@ class ProductParseResponse(SQLModel):
|
|||
price_amount: Optional[float] = None
|
||||
price_currency: Optional[str] = None
|
||||
size_ml: Optional[float] = None
|
||||
full_weight_g: Optional[float] = None
|
||||
empty_weight_g: Optional[float] = None
|
||||
pao_months: Optional[int] = None
|
||||
inci: Optional[list[str]] = None
|
||||
actives: Optional[list[ActiveIngredient]] = None
|
||||
|
|
@ -234,8 +230,7 @@ class InventoryCreate(SQLModel):
|
|||
opened_at: Optional[date] = None
|
||||
finished_at: Optional[date] = None
|
||||
expiry_date: Optional[date] = None
|
||||
current_weight_g: Optional[float] = None
|
||||
last_weighed_at: Optional[date] = None
|
||||
remaining_level: Optional[RemainingLevel] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
|
|
@ -244,11 +239,173 @@ class InventoryUpdate(SQLModel):
|
|||
opened_at: Optional[date] = None
|
||||
finished_at: Optional[date] = None
|
||||
expiry_date: Optional[date] = None
|
||||
current_weight_g: Optional[float] = None
|
||||
last_weighed_at: Optional[date] = None
|
||||
remaining_level: Optional[RemainingLevel] = 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -324,15 +481,6 @@ def _estimated_amount_per_use(category: ProductCategory) -> float | None:
|
|||
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:
|
||||
if product.price_amount is None or product.price_currency is None:
|
||||
return None
|
||||
|
|
@ -342,8 +490,6 @@ def _price_per_use_pln(product: Product) -> float | None:
|
|||
return None
|
||||
|
||||
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:
|
||||
return None
|
||||
|
||||
|
|
@ -518,8 +664,10 @@ def create_product(data: ProductCreate, session: Session = Depends(get_session))
|
|||
if payload.get("price_currency"):
|
||||
payload["price_currency"] = str(payload["price_currency"]).upper()
|
||||
|
||||
product_id = uuid4()
|
||||
product = Product(
|
||||
id=uuid4(),
|
||||
id=product_id,
|
||||
short_id=str(product_id)[:8],
|
||||
**payload,
|
||||
)
|
||||
session.add(product)
|
||||
|
|
@ -599,8 +747,6 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine):
|
|||
"price_amount": number,
|
||||
"price_currency": string,
|
||||
"size_ml": number,
|
||||
"full_weight_g": number,
|
||||
"empty_weight_g": number,
|
||||
"pao_months": integer,
|
||||
"inci": [string, ...],
|
||||
"actives": [
|
||||
|
|
@ -869,7 +1015,13 @@ def _ev(v: object) -> str:
|
|||
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)
|
||||
snapshot = session.exec(
|
||||
select(SkinConditionSnapshot).order_by(
|
||||
|
|
@ -892,9 +1044,14 @@ def _build_shopping_context(session: Session, reference_date: date) -> str:
|
|||
else:
|
||||
skin_lines.append(" (brak danych)")
|
||||
|
||||
products = _get_shopping_products(session)
|
||||
if products is None:
|
||||
products = _get_shopping_products(session)
|
||||
|
||||
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 = (
|
||||
session.exec(
|
||||
select(ProductInventory).where(
|
||||
|
|
@ -910,15 +1067,31 @@ def _build_shopping_context(session: Session, reference_date: date) -> str:
|
|||
|
||||
products_lines = ["POSIADANE PRODUKTY:"]
|
||||
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:
|
||||
active_inv = [i for i in inv_by_product.get(p.id, []) if i.finished_at is None]
|
||||
has_stock = len(active_inv) > 0 # any unfinished inventory = in stock
|
||||
stock = "✓" if has_stock else "✗"
|
||||
inventory_summary = _summarize_inventory_state(inv_by_product.get(p.id, []))
|
||||
stock = "✓" if inventory_summary["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_str = f", actives: {actives}" if actives else ""
|
||||
|
||||
ep = p.product_effect_profile
|
||||
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()
|
||||
if v >= 3
|
||||
}
|
||||
effects_str = f", effects: {effects}" if effects else ""
|
||||
|
||||
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(
|
||||
f" [{stock}] id={p.id} {p.name} ({p.brand or ''}) - {_ev(p.category)}, "
|
||||
f"targets: {targets}{actives_str}{effects_str}"
|
||||
f" sealed_backup_count={inventory_summary['sealed_backup_count']}"
|
||||
)
|
||||
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 (
|
||||
|
|
@ -972,33 +1187,47 @@ def _extract_requested_product_ids(
|
|||
return _shared_extract_requested_product_ids(args, max_ids=max_ids)
|
||||
|
||||
|
||||
_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ę.
|
||||
_SHOPPING_SYSTEM_PROMPT = """Jesteś asystentem zakupowym w dziedzinie pielęgnacji skóry.
|
||||
|
||||
LEGENDA:
|
||||
- [✓] = produkt dostępny w magazynie (nawet jeśli jest zapieczętowany)
|
||||
- [✗] = produkt niedostępny (brak w magazynie, wszystkie opakowania zużyte)
|
||||
Twoim zadaniem jest ocenić dwie rzeczy:
|
||||
1. czy w rutynie użytkownika istnieją realne luki produktowe,
|
||||
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
|
||||
1. Sugeruj TYLKO typy produktów, NIGDY konkretne marki (np. "Salicylic Acid 2% Masque", nie "La Roche-Posay")
|
||||
2. Produkty oznaczone [✗] to te, których NIE MA w magazynie - możesz je zasugerować
|
||||
3. Produkty oznaczone [✓] są już dostępne - nie sugeruj ich ponownie
|
||||
4. Bierz pod uwagę aktywne problemy skóry (acne, hyperpigmentacja, aging, etc.)
|
||||
5. Sugeruj realistyczną częstotliwość użycia (dzienna, 2-3x tygodniowo, etc.)
|
||||
6. Zachowaj kolejność warstw: cleanse → toner → serum → moisturizer → SPF
|
||||
7. Jeśli użytkownik ma uszkodzoną barierę, unikaj silnych eksfoliantów i retinoidów
|
||||
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)
|
||||
9. Odpowiadaj w języku polskim
|
||||
10. Używaj wyłącznie dozwolonych wartości enumów poniżej - nie twórz synonimów typu "night", "evening" ani "treatment"
|
||||
11. Możesz zwrócić pustą listę suggestions, jeśli nie widzisz realnej potrzeby zakupowej
|
||||
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
|
||||
13. `short_reason` ma być krótkim, 1-zdaniowym skrótem decyzji zakupowej
|
||||
14. `reason_to_buy_now` ma być konkretne i praktyczne, bez lania wody
|
||||
15. `reason_not_needed_if_budget_tight` jest opcjonalne - uzupełniaj tylko wtedy, gdy zakup nie jest pilny lub istnieje rozsądny kompromis
|
||||
16. `usage_cautions` ma być krótką listą praktycznych uwag; gdy brak istotnych zastrzeżeń zwróć pustą listę
|
||||
17. `priority` ustawiaj jako: high = wyraźna luka lub pilna potrzeba, medium = sensowne uzupełnienie, low = opcjonalny upgrade
|
||||
1. Najpierw oceń, czy użytkownik ma lukę w rutynie
|
||||
2. Potem oceń, czy któryś istniejący typ produktu jest wart odkupu teraz
|
||||
3. Nie rekomenduj ponownie produktu, który ma zdrowy zapas
|
||||
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. Dla kategorii podstawowych (`cleanser`, `moisturizer`, `spf`) niski stan i świeże użycie mają większe znaczenie niż dla produktów okazjonalnych
|
||||
6. Dla produktów okazjonalnych (`exfoliant`, `mask`, `spot_treatment`) świeżość użycia jest ważniejsza niż sam niski stan
|
||||
7. Jeśli `sealed_backup_count` > 0, zwykle nie rekomenduj odkupu
|
||||
8. Sugeruj TYLKO typy produktów, NIGDY konkretne marki (np. "Salicylic Acid 2% Masque", nie "La Roche-Posay")
|
||||
9. Bierz pod uwagę aktywne problemy skóry (acne, hyperpigmentacja, aging, etc.)
|
||||
10. Sugeruj realistyczną częstotliwość użycia (dzienna, 2-3x tygodniowo, etc.)
|
||||
11. Zachowaj kolejność warstw: cleanse → toner → serum → moisturizer → SPF
|
||||
12. Jeśli użytkownik ma uszkodzoną barierę, unikaj silnych eksfoliantów i retinoidów
|
||||
13. Zwracaj uwagę na ewentualne konflikty polecanych składników z tymi, które użytkownik już posiada
|
||||
14. Odpowiadaj w języku polskim
|
||||
15. Używaj wyłącznie dozwolonych wartości enumów poniżej - nie twórz synonimów typu "night", "evening" ani "treatment"
|
||||
16. Możesz zwrócić pustą listę suggestions, jeśli nie widzisz realnej potrzeby zakupowej
|
||||
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:
|
||||
- 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)
|
||||
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)
|
||||
last_used_on_by_product = build_last_used_on_by_product(
|
||||
session,
|
||||
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 = (
|
||||
f"Na podstawie poniższych danych przeanalizuj, jakie TYPY produktów "
|
||||
f"mogłyby uzupełnić rutynę pielęgnacyjną użytkownika.\n\n"
|
||||
"Przeanalizuj dane użytkownika i zaproponuj tylko te zakupy, które mają realny sens teraz.\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"
|
||||
"NARZEDZIA:\n"
|
||||
"- Masz dostep do funkcji: get_product_details.\n"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from .enums import (
|
|||
PartOfDay,
|
||||
PriceTier,
|
||||
ProductCategory,
|
||||
RemainingLevel,
|
||||
ResultFlag,
|
||||
RoutineRole,
|
||||
SexAtBirth,
|
||||
|
|
@ -58,6 +59,7 @@ __all__ = [
|
|||
"OverallSkinState",
|
||||
"PartOfDay",
|
||||
"PriceTier",
|
||||
"RemainingLevel",
|
||||
"ProductCategory",
|
||||
"ResultFlag",
|
||||
"RoutineRole",
|
||||
|
|
|
|||
|
|
@ -124,6 +124,13 @@ class PriceTier(str, Enum):
|
|||
LUXURY = "luxury"
|
||||
|
||||
|
||||
class RemainingLevel(str, Enum):
|
||||
HIGH = "high"
|
||||
MEDIUM = "medium"
|
||||
LOW = "low"
|
||||
NEARLY_EMPTY = "nearly_empty"
|
||||
|
||||
|
||||
class EvidenceLevel(str, Enum):
|
||||
LOW = "low"
|
||||
MIXED = "mixed"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from .enums import (
|
|||
IngredientFunction,
|
||||
PriceTier,
|
||||
ProductCategory,
|
||||
RemainingLevel,
|
||||
SkinConcern,
|
||||
SkinType,
|
||||
StrengthLevel,
|
||||
|
|
@ -97,8 +98,6 @@ class ProductBase(SQLModel):
|
|||
price_amount: float | None = Field(default=None, gt=0)
|
||||
price_currency: str | None = Field(default=None, min_length=3, max_length=3)
|
||||
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)
|
||||
|
||||
inci: list[str] = Field(default_factory=list)
|
||||
|
|
@ -129,7 +128,6 @@ class ProductBase(SQLModel):
|
|||
needle_length_mm: float | None = Field(default=None, gt=0)
|
||||
|
||||
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
|
||||
if 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:
|
||||
opened_items = [
|
||||
|
|
@ -365,8 +361,7 @@ class ProductInventory(SQLModel, table=True):
|
|||
opened_at: date | None = Field(default=None)
|
||||
finished_at: date | None = Field(default=None)
|
||||
expiry_date: date | None = Field(default=None)
|
||||
current_weight_g: float | None = Field(default=None, gt=0)
|
||||
last_weighed_at: date | None = Field(default=None)
|
||||
remaining_level: RemainingLevel | None = None
|
||||
notes: str | None = None
|
||||
|
||||
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(
|
||||
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
|
||||
data = r2.json()
|
||||
assert data["is_opened"] is True
|
||||
assert data["opened_at"] == "2026-01-15"
|
||||
assert data["remaining_level"] == "low"
|
||||
|
||||
|
||||
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):
|
||||
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
|
||||
data = r.json()
|
||||
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):
|
||||
|
|
@ -204,11 +208,16 @@ def test_parse_text_accepts_numeric_strength_levels(client, monkeypatch):
|
|||
|
||||
class _FakeResponse:
|
||||
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}]}'
|
||||
)
|
||||
|
||||
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"})
|
||||
assert r.status_code == 200
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ from innercontext.api.products import (
|
|||
ProductSuggestion,
|
||||
ShoppingSuggestionResponse,
|
||||
_build_shopping_context,
|
||||
_compute_days_since_last_used,
|
||||
_compute_replenishment_score,
|
||||
_extract_requested_product_ids,
|
||||
build_product_details_tool_handler,
|
||||
)
|
||||
|
|
@ -68,7 +70,12 @@ def test_build_shopping_context(session: Session):
|
|||
session.commit()
|
||||
|
||||
# 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.commit()
|
||||
|
||||
|
|
@ -87,9 +94,89 @@ def test_build_shopping_context(session: Session):
|
|||
assert "Soothing Serum" in ctx
|
||||
assert f"id={p.id}" in ctx
|
||||
assert "BrandX" in ctx
|
||||
assert "targets: ['redness']" in ctx
|
||||
assert "actives: ['Centella']" in ctx
|
||||
assert "effects: {'soothing': 4}" in ctx
|
||||
assert "targets=['redness']" in ctx
|
||||
assert "actives=['Centella']" 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):
|
||||
|
|
|
|||
|
|
@ -95,8 +95,11 @@
|
|||
"inventory_openedDate": "Opened date",
|
||||
"inventory_finishedDate": "Finished date",
|
||||
"inventory_expiryDate": "Expiry date",
|
||||
"inventory_currentWeight": "Current weight (g)",
|
||||
"inventory_lastWeighed": "Last weighed",
|
||||
"inventory_remainingLevel": "Remaining product level",
|
||||
"inventory_remainingHigh": "high",
|
||||
"inventory_remainingMedium": "medium",
|
||||
"inventory_remainingLow": "low",
|
||||
"inventory_remainingNearlyEmpty": "nearly empty",
|
||||
"inventory_notes": "Notes",
|
||||
"inventory_badgeOpen": "Open",
|
||||
"inventory_badgeSealed": "Sealed",
|
||||
|
|
@ -104,8 +107,6 @@
|
|||
"inventory_exp": "Exp:",
|
||||
"inventory_opened": "Opened:",
|
||||
"inventory_finished": "Finished:",
|
||||
"inventory_remaining": "g remaining",
|
||||
"inventory_weighed": "Weighed:",
|
||||
"inventory_confirmDelete": "Delete this package?",
|
||||
|
||||
"routines_title": "Routines",
|
||||
|
|
@ -514,7 +515,6 @@
|
|||
"productForm_isTool": "Is tool (e.g. dermaroller)",
|
||||
"productForm_needleLengthMm": "Needle length (mm, tools only)",
|
||||
"productForm_personalNotes": "Personal notes",
|
||||
"productForm_repurchaseIntent": "Repurchase intent",
|
||||
"productForm_toleranceNotes": "Tolerance notes",
|
||||
"productForm_toleranceNotesPlaceholder": "e.g. Causes mild stinging, fine after 2 weeks",
|
||||
|
||||
|
|
|
|||
|
|
@ -97,8 +97,11 @@
|
|||
"inventory_openedDate": "Data otwarcia",
|
||||
"inventory_finishedDate": "Data skończenia",
|
||||
"inventory_expiryDate": "Data ważności",
|
||||
"inventory_currentWeight": "Aktualna waga (g)",
|
||||
"inventory_lastWeighed": "Ostatnie ważenie",
|
||||
"inventory_remainingLevel": "Poziom pozostałego produktu:",
|
||||
"inventory_remainingHigh": "dużo",
|
||||
"inventory_remainingMedium": "średnio",
|
||||
"inventory_remainingLow": "mało",
|
||||
"inventory_remainingNearlyEmpty": "prawie puste",
|
||||
"inventory_notes": "Notatki",
|
||||
"inventory_badgeOpen": "Otwarte",
|
||||
"inventory_badgeSealed": "Zamknięte",
|
||||
|
|
@ -106,8 +109,6 @@
|
|||
"inventory_exp": "Wazność:",
|
||||
"inventory_opened": "Otwarto:",
|
||||
"inventory_finished": "Skończono:",
|
||||
"inventory_remaining": "g pozostało",
|
||||
"inventory_weighed": "Ważono:",
|
||||
"inventory_confirmDelete": "Usunąć to opakowanie?",
|
||||
|
||||
"routines_title": "Rutyny",
|
||||
|
|
@ -528,7 +529,6 @@
|
|||
"productForm_isTool": "To narzędzie (np. dermaroller)",
|
||||
"productForm_needleLengthMm": "Długość igły (mm, tylko narzędzia)",
|
||||
"productForm_personalNotes": "Notatki osobiste",
|
||||
"productForm_repurchaseIntent": "Zamiar ponownego zakupu",
|
||||
"productForm_toleranceNotes": "Notatki o tolerancji",
|
||||
"productForm_toleranceNotesPlaceholder": "np. Lekkie pieczenie, ustępuje po 2 tygodniach",
|
||||
|
||||
|
|
|
|||
|
|
@ -135,8 +135,6 @@ export interface ProductParseResponse {
|
|||
price_amount?: number;
|
||||
price_currency?: string;
|
||||
size_ml?: number;
|
||||
full_weight_g?: number;
|
||||
empty_weight_g?: number;
|
||||
pao_months?: number;
|
||||
inci?: string[];
|
||||
actives?: ActiveIngredient[];
|
||||
|
|
|
|||
|
|
@ -165,8 +165,6 @@
|
|||
let sku = $state(untrack(() => product?.sku ?? ''));
|
||||
let barcode = $state(untrack(() => product?.barcode ?? ''));
|
||||
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 phMin = $state(untrack(() => (product?.ph_min != null ? String(product.ph_min) : '')));
|
||||
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.leave_on != null) leaveOn = String(r.leave_on);
|
||||
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.ph_min != null) phMin = String(r.ph_min);
|
||||
if (r.ph_max != null) phMax = String(r.ph_max);
|
||||
|
|
@ -281,9 +277,6 @@
|
|||
let pregnancySafe = $state(
|
||||
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
|
||||
const cr = untrack(() => product?.context_rules);
|
||||
|
|
@ -403,8 +396,6 @@
|
|||
sku,
|
||||
barcode,
|
||||
sizeMl,
|
||||
fullWeightG,
|
||||
emptyWeightG,
|
||||
paoMonths,
|
||||
phMin,
|
||||
phMax,
|
||||
|
|
@ -428,7 +419,6 @@
|
|||
essentialOilsFree,
|
||||
alcoholDenatFree,
|
||||
pregnancySafe,
|
||||
personalRepurchaseIntent,
|
||||
ctxAfterShaving,
|
||||
ctxAfterAcids,
|
||||
ctxAfterRetinoids,
|
||||
|
|
@ -723,8 +713,6 @@
|
|||
bind:priceAmount
|
||||
bind:priceCurrency
|
||||
bind:sizeMl
|
||||
bind:fullWeightG
|
||||
bind:emptyWeightG
|
||||
bind:paoMonths
|
||||
bind:phMin
|
||||
bind:phMax
|
||||
|
|
@ -743,10 +731,7 @@
|
|||
{@const NotesSection = mod.default}
|
||||
<NotesSection
|
||||
visible={editSection === 'notes'}
|
||||
{selectClass}
|
||||
{textareaClass}
|
||||
{tristate}
|
||||
bind:personalRepurchaseIntent
|
||||
bind:personalToleranceNotes
|
||||
/>
|
||||
{/await}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@
|
|||
priceAmount = $bindable(''),
|
||||
priceCurrency = $bindable('PLN'),
|
||||
sizeMl = $bindable(''),
|
||||
fullWeightG = $bindable(''),
|
||||
emptyWeightG = $bindable(''),
|
||||
paoMonths = $bindable(''),
|
||||
phMin = $bindable(''),
|
||||
phMax = $bindable(''),
|
||||
|
|
@ -27,8 +25,6 @@
|
|||
priceAmount?: string;
|
||||
priceCurrency?: string;
|
||||
sizeMl?: string;
|
||||
fullWeightG?: string;
|
||||
emptyWeightG?: string;
|
||||
paoMonths?: string;
|
||||
phMin?: 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} />
|
||||
</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">
|
||||
<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} />
|
||||
|
|
|
|||
|
|
@ -3,21 +3,13 @@
|
|||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
type TriOption = { value: string; label: string };
|
||||
|
||||
let {
|
||||
visible = false,
|
||||
selectClass,
|
||||
textareaClass,
|
||||
tristate,
|
||||
personalRepurchaseIntent = $bindable(''),
|
||||
personalToleranceNotes = $bindable('')
|
||||
}: {
|
||||
visible?: boolean;
|
||||
selectClass: string;
|
||||
textareaClass: string;
|
||||
tristate: TriOption[];
|
||||
personalRepurchaseIntent?: string;
|
||||
personalToleranceNotes?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
|
@ -25,15 +17,6 @@
|
|||
<Card class={visible ? '' : 'hidden'}>
|
||||
<CardHeader><CardTitle>{m["productForm_personalNotes"]()}</CardTitle></CardHeader>
|
||||
<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">
|
||||
<Label for="personal_tolerance_notes">{m["productForm_toleranceNotes"]()}</Label>
|
||||
<textarea
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export type OverallSkinState = "excellent" | "good" | "fair" | "poor";
|
|||
export type PartOfDay = "am" | "pm";
|
||||
export type PriceTier = "budget" | "mid" | "premium" | "luxury";
|
||||
export type PriceTierSource = "category" | "fallback" | "insufficient_data";
|
||||
export type RemainingLevel = "high" | "medium" | "low" | "nearly_empty";
|
||||
export type ProductCategory =
|
||||
| "cleanser"
|
||||
| "toner"
|
||||
|
|
@ -129,8 +130,7 @@ export interface ProductInventory {
|
|||
opened_at?: string;
|
||||
finished_at?: string;
|
||||
expiry_date?: string;
|
||||
current_weight_g?: number;
|
||||
last_weighed_at?: string;
|
||||
remaining_level?: RemainingLevel;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
product?: Product;
|
||||
|
|
@ -155,8 +155,6 @@ export interface Product {
|
|||
price_per_use_pln?: number;
|
||||
price_tier_source?: PriceTierSource;
|
||||
size_ml?: number;
|
||||
full_weight_g?: number;
|
||||
empty_weight_g?: number;
|
||||
pao_months?: number;
|
||||
inci: string[];
|
||||
actives?: ActiveIngredient[];
|
||||
|
|
@ -176,7 +174,6 @@ export interface Product {
|
|||
is_tool: boolean;
|
||||
needle_length_mm?: number;
|
||||
personal_tolerance_notes?: string;
|
||||
personal_repurchase_intent?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
inventory: ProductInventory[];
|
||||
|
|
|
|||
|
|
@ -127,8 +127,6 @@ export const actions: Actions = {
|
|||
|
||||
// Optional numbers
|
||||
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.ph_min = parseOptionalFloat(form.get('ph_min') 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';
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
@ -192,10 +190,8 @@ export const actions: Actions = {
|
|||
if (finishedAt) body.finished_at = finishedAt;
|
||||
const expiry = form.get('expiry_date');
|
||||
if (expiry) body.expiry_date = expiry;
|
||||
const weight = form.get('current_weight_g');
|
||||
if (weight) body.current_weight_g = Number(weight);
|
||||
const lastWeighed = form.get('last_weighed_at');
|
||||
if (lastWeighed) body.last_weighed_at = lastWeighed;
|
||||
const remainingLevel = form.get('remaining_level');
|
||||
if (remainingLevel) body.remaining_level = remainingLevel;
|
||||
const notes = form.get('notes');
|
||||
if (notes) body.notes = notes;
|
||||
try {
|
||||
|
|
@ -219,10 +215,8 @@ export const actions: Actions = {
|
|||
body.finished_at = finishedAt || null;
|
||||
const expiry = form.get('expiry_date');
|
||||
body.expiry_date = expiry || null;
|
||||
const weight = form.get('current_weight_g');
|
||||
body.current_weight_g = weight ? Number(weight) : null;
|
||||
const lastWeighed = form.get('last_weighed_at');
|
||||
body.last_weighed_at = lastWeighed || null;
|
||||
const remainingLevel = form.get('remaining_level');
|
||||
body.remaining_level = remainingLevel || null;
|
||||
const notes = form.get('notes');
|
||||
body.notes = notes || null;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
|
@ -15,8 +16,16 @@
|
|||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
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 addInventoryOpened = $state(false);
|
||||
let editingInventoryId = $state<string | null>(null);
|
||||
let editingInventoryOpened = $state<Record<string, boolean>>({});
|
||||
let activeTab = $state<'inventory' | 'edit'>('edit');
|
||||
let isEditDirty = $state(false);
|
||||
let editSaveVersion = $state(0);
|
||||
|
|
@ -60,6 +69,14 @@
|
|||
if (value === 'luxury') return m['productForm_priceLuxury']();
|
||||
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>
|
||||
|
||||
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
|
||||
|
|
@ -147,41 +164,43 @@
|
|||
<CardHeader class="pb-2">
|
||||
<CardTitle class="text-base">{m["inventory_addPackage"]()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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">
|
||||
<input type="checkbox" id="add_is_opened" name="is_opened" value="true" class="h-4 w-4" />
|
||||
<Label for="add_is_opened">{m["inventory_alreadyOpened"]()}</Label>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="add_opened_at">{m["inventory_openedDate"]()}</Label>
|
||||
<Input id="add_opened_at" name="opened_at" type="date" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="add_finished_at">{m["inventory_finishedDate"]()}</Label>
|
||||
<Input id="add_finished_at" name="finished_at" type="date" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="add_expiry_date">{m["inventory_expiryDate"]()}</Label>
|
||||
<Input id="add_expiry_date" name="expiry_date" type="date" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="add_current_weight_g">{m["inventory_currentWeight"]()}</Label>
|
||||
<Input id="add_current_weight_g" name="current_weight_g" type="number" min="0" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="add_last_weighed_at">{m["inventory_lastWeighed"]()}</Label>
|
||||
<Input id="add_last_weighed_at" name="last_weighed_at" type="date" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="add_notes">{m.inventory_notes()}</Label>
|
||||
<Input id="add_notes" name="notes" />
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<Button type="submit" size="sm">{m.common_add()}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<Label for="add_opened_at">{m["inventory_openedDate"]()}</Label>
|
||||
<Input id="add_opened_at" name="opened_at" type="date" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="add_finished_at">{m["inventory_finishedDate"]()}</Label>
|
||||
<Input id="add_finished_at" name="finished_at" type="date" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="add_expiry_date">{m["inventory_expiryDate"]()}</Label>
|
||||
<Input id="add_expiry_date" name="expiry_date" type="date" />
|
||||
</div>
|
||||
{#if addInventoryOpened}
|
||||
<SimpleSelect
|
||||
id="add_remaining_level"
|
||||
name="remaining_level"
|
||||
label={m["inventory_remainingLevel"]()}
|
||||
options={remainingLevelOptions}
|
||||
placeholder={m.common_unknown()}
|
||||
/>
|
||||
{/if}
|
||||
<div class="space-y-1">
|
||||
<Label for="add_notes">{m.inventory_notes()}</Label>
|
||||
<Input id="add_notes" name="notes" />
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<Button type="submit" size="sm">{m.common_add()}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
|
|
@ -206,11 +225,8 @@
|
|||
{#if pkg.finished_at}
|
||||
<span class="text-muted-foreground">{m.inventory_finished()} {pkg.finished_at.slice(0, 10)}</span>
|
||||
{/if}
|
||||
{#if pkg.current_weight_g}
|
||||
<span class="text-muted-foreground">{pkg.current_weight_g}g {m.inventory_remaining()}</span>
|
||||
{/if}
|
||||
{#if pkg.last_weighed_at}
|
||||
<span class="text-muted-foreground">{m.inventory_weighed()} {pkg.last_weighed_at.slice(0, 10)}</span>
|
||||
{#if pkg.is_opened && pkg.remaining_level}
|
||||
<span class="text-muted-foreground">{m["inventory_remainingLevel"]()} {remainingLevelLabel(pkg.remaining_level)}</span>
|
||||
{/if}
|
||||
{#if pkg.notes}
|
||||
<span class="text-muted-foreground">{pkg.notes}</span>
|
||||
|
|
@ -220,7 +236,14 @@
|
|||
<Button
|
||||
variant="ghost"
|
||||
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()}
|
||||
</Button>
|
||||
|
|
@ -256,7 +279,11 @@
|
|||
id="edit_is_opened_{pkg.id}"
|
||||
name="is_opened"
|
||||
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"
|
||||
/>
|
||||
<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>
|
||||
<Input id="edit_expiry_{pkg.id}" name="expiry_date" type="date" value={pkg.expiry_date?.slice(0, 10) ?? ''} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_weight_{pkg.id}">{m["inventory_currentWeight"]()}</Label>
|
||||
<Input id="edit_weight_{pkg.id}" name="current_weight_g" type="number" min="0" value={pkg.current_weight_g ?? ''} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_last_weighed_{pkg.id}">{m["inventory_lastWeighed"]()}</Label>
|
||||
<Input id="edit_last_weighed_{pkg.id}" name="last_weighed_at" type="date" value={pkg.last_weighed_at?.slice(0, 10) ?? ''} />
|
||||
</div>
|
||||
{#if editingInventoryOpened[pkg.id] ?? pkg.is_opened}
|
||||
<SimpleSelect
|
||||
id={`edit_remaining_level_${pkg.id}`}
|
||||
name="remaining_level"
|
||||
label={m["inventory_remainingLevel"]()}
|
||||
options={remainingLevelOptions}
|
||||
placeholder={m.common_unknown()}
|
||||
value={pkg.remaining_level ?? ''}
|
||||
/>
|
||||
{/if}
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_notes_{pkg.id}">{m.inventory_notes()}</Label>
|
||||
<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);
|
||||
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);
|
||||
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';
|
||||
|
||||
// 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);
|
||||
if (v !== undefined) payload[field] = v;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue