feat(products): compute price tiers from objective price/use
This commit is contained in:
parent
c5ea38880c
commit
83ba4cc5c0
13 changed files with 664 additions and 48 deletions
|
|
@ -0,0 +1,45 @@
|
||||||
|
"""replace_price_tier_with_objective_price_fields
|
||||||
|
|
||||||
|
Revision ID: 7c91e4b2af38
|
||||||
|
Revises: e4f5a6b7c8d9
|
||||||
|
Create Date: 2026-03-04 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "7c91e4b2af38"
|
||||||
|
down_revision: Union[str, None] = "e4f5a6b7c8d9"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("products", sa.Column("price_amount", sa.Float(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"products", sa.Column("price_currency", sa.String(length=3), nullable=True)
|
||||||
|
)
|
||||||
|
op.drop_index(op.f("ix_products_price_tier"), table_name="products")
|
||||||
|
op.drop_column("products", "price_tier")
|
||||||
|
op.execute("DROP TYPE IF EXISTS pricetier")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("CREATE TYPE pricetier AS ENUM ('BUDGET', 'MID', 'PREMIUM', 'LUXURY')")
|
||||||
|
op.add_column(
|
||||||
|
"products",
|
||||||
|
sa.Column(
|
||||||
|
"price_tier",
|
||||||
|
sa.Enum("BUDGET", "MID", "PREMIUM", "LUXURY", name="pricetier"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_products_price_tier"), "products", ["price_tier"], unique=False
|
||||||
|
)
|
||||||
|
op.drop_column("products", "price_currency")
|
||||||
|
op.drop_column("products", "price_amount")
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import json
|
import json
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Optional
|
from typing import Literal, Optional
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
@ -17,6 +17,7 @@ from innercontext.llm import (
|
||||||
get_creative_config,
|
get_creative_config,
|
||||||
get_extraction_config,
|
get_extraction_config,
|
||||||
)
|
)
|
||||||
|
from innercontext.services.fx import convert_to_pln
|
||||||
from innercontext.models import (
|
from innercontext.models import (
|
||||||
Product,
|
Product,
|
||||||
ProductBase,
|
ProductBase,
|
||||||
|
|
@ -67,8 +68,11 @@ class ProductUpdate(SQLModel):
|
||||||
absorption_speed: Optional[AbsorptionSpeed] = None
|
absorption_speed: Optional[AbsorptionSpeed] = None
|
||||||
leave_on: Optional[bool] = None
|
leave_on: Optional[bool] = None
|
||||||
|
|
||||||
price_tier: Optional[PriceTier] = None
|
price_amount: Optional[float] = 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
|
||||||
|
|
@ -119,7 +123,8 @@ class ProductParseResponse(SQLModel):
|
||||||
texture: Optional[TextureType] = None
|
texture: Optional[TextureType] = None
|
||||||
absorption_speed: Optional[AbsorptionSpeed] = None
|
absorption_speed: Optional[AbsorptionSpeed] = None
|
||||||
leave_on: Optional[bool] = None
|
leave_on: Optional[bool] = None
|
||||||
price_tier: Optional[PriceTier] = None
|
price_amount: Optional[float] = None
|
||||||
|
price_currency: Optional[str] = None
|
||||||
size_ml: Optional[float] = None
|
size_ml: Optional[float] = None
|
||||||
full_weight_g: Optional[float] = None
|
full_weight_g: Optional[float] = None
|
||||||
empty_weight_g: Optional[float] = None
|
empty_weight_g: Optional[float] = None
|
||||||
|
|
@ -213,6 +218,188 @@ class _ShoppingSuggestionsOut(PydanticBase):
|
||||||
reasoning: str
|
reasoning: str
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pricing helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_MIN_PRODUCTS_FOR_PRICE_TIER = 8
|
||||||
|
_MIN_CATEGORY_SIZE_FOR_FALLBACK = 4
|
||||||
|
_ESTIMATED_AMOUNT_PER_USE: dict[ProductCategory, float] = {
|
||||||
|
ProductCategory.CLEANSER: 1.5,
|
||||||
|
ProductCategory.TONER: 1.5,
|
||||||
|
ProductCategory.ESSENCE: 1.0,
|
||||||
|
ProductCategory.SERUM: 0.35,
|
||||||
|
ProductCategory.MOISTURIZER: 0.8,
|
||||||
|
ProductCategory.SPF: 1.2,
|
||||||
|
ProductCategory.MASK: 2.5,
|
||||||
|
ProductCategory.EXFOLIANT: 0.7,
|
||||||
|
ProductCategory.HAIR_TREATMENT: 1.0,
|
||||||
|
ProductCategory.SPOT_TREATMENT: 0.1,
|
||||||
|
ProductCategory.OIL: 0.35,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
amount_per_use = _estimated_amount_per_use(product.category)
|
||||||
|
if amount_per_use is None or amount_per_use <= 0:
|
||||||
|
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
|
||||||
|
|
||||||
|
uses_per_pack = pack_amount / amount_per_use
|
||||||
|
if uses_per_pack <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
price_pln = convert_to_pln(product.price_amount, product.price_currency.upper())
|
||||||
|
if price_pln is None:
|
||||||
|
return None
|
||||||
|
return price_pln / uses_per_pack
|
||||||
|
|
||||||
|
|
||||||
|
def _percentile(sorted_values: list[float], fraction: float) -> float:
|
||||||
|
if not sorted_values:
|
||||||
|
raise ValueError("sorted_values cannot be empty")
|
||||||
|
if len(sorted_values) == 1:
|
||||||
|
return sorted_values[0]
|
||||||
|
|
||||||
|
position = (len(sorted_values) - 1) * fraction
|
||||||
|
low = int(position)
|
||||||
|
high = min(low + 1, len(sorted_values) - 1)
|
||||||
|
weight = position - low
|
||||||
|
return sorted_values[low] * (1 - weight) + sorted_values[high] * weight
|
||||||
|
|
||||||
|
|
||||||
|
def _tier_from_thresholds(
|
||||||
|
value: float,
|
||||||
|
*,
|
||||||
|
p25: float,
|
||||||
|
p50: float,
|
||||||
|
p75: float,
|
||||||
|
) -> PriceTier:
|
||||||
|
if value <= p25:
|
||||||
|
return PriceTier.BUDGET
|
||||||
|
if value <= p50:
|
||||||
|
return PriceTier.MID
|
||||||
|
if value <= p75:
|
||||||
|
return PriceTier.PREMIUM
|
||||||
|
return PriceTier.LUXURY
|
||||||
|
|
||||||
|
|
||||||
|
def _thresholds(values: list[float]) -> tuple[float, float, float]:
|
||||||
|
sorted_vals = sorted(values)
|
||||||
|
return (
|
||||||
|
_percentile(sorted_vals, 0.25),
|
||||||
|
_percentile(sorted_vals, 0.50),
|
||||||
|
_percentile(sorted_vals, 0.75),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_pricing_outputs(
|
||||||
|
products: list[Product],
|
||||||
|
) -> dict[
|
||||||
|
UUID,
|
||||||
|
tuple[
|
||||||
|
PriceTier | None,
|
||||||
|
float | None,
|
||||||
|
Literal["category", "fallback", "insufficient_data"] | None,
|
||||||
|
],
|
||||||
|
]:
|
||||||
|
price_per_use_by_id: dict[UUID, float] = {}
|
||||||
|
grouped: dict[ProductCategory, list[tuple[UUID, float]]] = {}
|
||||||
|
|
||||||
|
for product in products:
|
||||||
|
ppu = _price_per_use_pln(product)
|
||||||
|
if ppu is None:
|
||||||
|
continue
|
||||||
|
price_per_use_by_id[product.id] = ppu
|
||||||
|
grouped.setdefault(product.category, []).append((product.id, ppu))
|
||||||
|
|
||||||
|
outputs: dict[
|
||||||
|
UUID,
|
||||||
|
tuple[
|
||||||
|
PriceTier | None,
|
||||||
|
float | None,
|
||||||
|
Literal["category", "fallback", "insufficient_data"] | None,
|
||||||
|
],
|
||||||
|
] = {
|
||||||
|
p.id: (
|
||||||
|
None,
|
||||||
|
price_per_use_by_id.get(p.id),
|
||||||
|
"insufficient_data" if p.id in price_per_use_by_id else None,
|
||||||
|
)
|
||||||
|
for p in products
|
||||||
|
}
|
||||||
|
|
||||||
|
fallback_rows: list[tuple[UUID, float]] = []
|
||||||
|
for product in products:
|
||||||
|
ppu = price_per_use_by_id.get(product.id)
|
||||||
|
if ppu is None:
|
||||||
|
continue
|
||||||
|
if product.is_tool or product.is_medication or not product.leave_on:
|
||||||
|
continue
|
||||||
|
fallback_rows.append((product.id, ppu))
|
||||||
|
|
||||||
|
fallback_thresholds: tuple[float, float, float] | None = None
|
||||||
|
if len(fallback_rows) >= _MIN_PRODUCTS_FOR_PRICE_TIER:
|
||||||
|
fallback_thresholds = _thresholds([ppu for _, ppu in fallback_rows])
|
||||||
|
|
||||||
|
for category_rows in grouped.values():
|
||||||
|
if len(category_rows) < _MIN_PRODUCTS_FOR_PRICE_TIER:
|
||||||
|
if (
|
||||||
|
len(category_rows) >= _MIN_CATEGORY_SIZE_FOR_FALLBACK
|
||||||
|
and fallback_thresholds is not None
|
||||||
|
):
|
||||||
|
p25, p50, p75 = fallback_thresholds
|
||||||
|
for product_id, ppu in category_rows:
|
||||||
|
tier = _tier_from_thresholds(ppu, p25=p25, p50=p50, p75=p75)
|
||||||
|
outputs[product_id] = (tier, ppu, "fallback")
|
||||||
|
continue
|
||||||
|
|
||||||
|
p25, p50, p75 = _thresholds([ppu for _, ppu in category_rows])
|
||||||
|
|
||||||
|
for product_id, ppu in category_rows:
|
||||||
|
tier = _tier_from_thresholds(ppu, p25=p25, p50=p50, p75=p75)
|
||||||
|
outputs[product_id] = (tier, ppu, "category")
|
||||||
|
|
||||||
|
return outputs
|
||||||
|
|
||||||
|
|
||||||
|
def _with_pricing(
|
||||||
|
view: ProductPublic,
|
||||||
|
pricing: tuple[
|
||||||
|
PriceTier | None,
|
||||||
|
float | None,
|
||||||
|
Literal["category", "fallback", "insufficient_data"] | None,
|
||||||
|
],
|
||||||
|
) -> ProductPublic:
|
||||||
|
price_tier, price_per_use_pln, price_tier_source = pricing
|
||||||
|
view.price_tier = price_tier
|
||||||
|
view.price_per_use_pln = price_per_use_pln
|
||||||
|
view.price_tier_source = price_tier_source
|
||||||
|
return view
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Product routes
|
# Product routes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -267,8 +454,12 @@ def list_products(
|
||||||
inv_by_product.setdefault(inv.product_id, []).append(inv)
|
inv_by_product.setdefault(inv.product_id, []).append(inv)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
pricing_pool = list(session.exec(select(Product)).all()) if products else []
|
||||||
|
pricing_outputs = _compute_pricing_outputs(pricing_pool)
|
||||||
|
|
||||||
for p in products:
|
for p in products:
|
||||||
r = ProductWithInventory.model_validate(p, from_attributes=True)
|
r = ProductWithInventory.model_validate(p, from_attributes=True)
|
||||||
|
_with_pricing(r, pricing_outputs.get(p.id, (None, None, None)))
|
||||||
r.inventory = inv_by_product.get(p.id, [])
|
r.inventory = inv_by_product.get(p.id, [])
|
||||||
results.append(r)
|
results.append(r)
|
||||||
return results
|
return results
|
||||||
|
|
@ -276,9 +467,13 @@ def list_products(
|
||||||
|
|
||||||
@router.post("", response_model=ProductPublic, status_code=201)
|
@router.post("", response_model=ProductPublic, status_code=201)
|
||||||
def create_product(data: ProductCreate, session: Session = Depends(get_session)):
|
def create_product(data: ProductCreate, session: Session = Depends(get_session)):
|
||||||
|
payload = data.model_dump()
|
||||||
|
if payload.get("price_currency"):
|
||||||
|
payload["price_currency"] = str(payload["price_currency"]).upper()
|
||||||
|
|
||||||
product = Product(
|
product = Product(
|
||||||
id=uuid4(),
|
id=uuid4(),
|
||||||
**data.model_dump(),
|
**payload,
|
||||||
)
|
)
|
||||||
session.add(product)
|
session.add(product)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
@ -324,8 +519,6 @@ texture: "watery" | "gel" | "emulsion" | "cream" | "oil" | "balm" | "foam" | "fl
|
||||||
|
|
||||||
absorption_speed: "very_fast" | "fast" | "moderate" | "slow" | "very_slow"
|
absorption_speed: "very_fast" | "fast" | "moderate" | "slow" | "very_slow"
|
||||||
|
|
||||||
price_tier: "budget" | "mid" | "premium" | "luxury"
|
|
||||||
|
|
||||||
recommended_for (array, pick applicable):
|
recommended_for (array, pick applicable):
|
||||||
"dry" | "oily" | "combination" | "sensitive" | "normal" | "acne_prone"
|
"dry" | "oily" | "combination" | "sensitive" | "normal" | "acne_prone"
|
||||||
|
|
||||||
|
|
@ -355,7 +548,8 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine):
|
||||||
"texture": string,
|
"texture": string,
|
||||||
"absorption_speed": string,
|
"absorption_speed": string,
|
||||||
"leave_on": boolean,
|
"leave_on": boolean,
|
||||||
"price_tier": string,
|
"price_amount": number,
|
||||||
|
"price_currency": string,
|
||||||
"size_ml": number,
|
"size_ml": number,
|
||||||
"full_weight_g": number,
|
"full_weight_g": number,
|
||||||
"empty_weight_g": number,
|
"empty_weight_g": number,
|
||||||
|
|
@ -440,10 +634,14 @@ def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
|
||||||
@router.get("/{product_id}", response_model=ProductWithInventory)
|
@router.get("/{product_id}", response_model=ProductWithInventory)
|
||||||
def get_product(product_id: UUID, session: Session = Depends(get_session)):
|
def get_product(product_id: UUID, session: Session = Depends(get_session)):
|
||||||
product = get_or_404(session, Product, product_id)
|
product = get_or_404(session, Product, product_id)
|
||||||
|
pricing_pool = list(session.exec(select(Product)).all())
|
||||||
|
pricing_outputs = _compute_pricing_outputs(pricing_pool)
|
||||||
|
|
||||||
inventory = session.exec(
|
inventory = session.exec(
|
||||||
select(ProductInventory).where(ProductInventory.product_id == product_id)
|
select(ProductInventory).where(ProductInventory.product_id == product_id)
|
||||||
).all()
|
).all()
|
||||||
result = ProductWithInventory.model_validate(product, from_attributes=True)
|
result = ProductWithInventory.model_validate(product, from_attributes=True)
|
||||||
|
_with_pricing(result, pricing_outputs.get(product.id, (None, None, None)))
|
||||||
result.inventory = list(inventory)
|
result.inventory = list(inventory)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
@ -453,12 +651,19 @@ def update_product(
|
||||||
product_id: UUID, data: ProductUpdate, session: Session = Depends(get_session)
|
product_id: UUID, data: ProductUpdate, session: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
product = get_or_404(session, Product, product_id)
|
product = get_or_404(session, Product, product_id)
|
||||||
for key, value in data.model_dump(exclude_unset=True).items():
|
patch_data = data.model_dump(exclude_unset=True)
|
||||||
|
if patch_data.get("price_currency"):
|
||||||
|
patch_data["price_currency"] = str(patch_data["price_currency"]).upper()
|
||||||
|
|
||||||
|
for key, value in patch_data.items():
|
||||||
setattr(product, key, value)
|
setattr(product, key, value)
|
||||||
session.add(product)
|
session.add(product)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(product)
|
session.refresh(product)
|
||||||
return product
|
pricing_pool = list(session.exec(select(Product)).all())
|
||||||
|
pricing_outputs = _compute_pricing_outputs(pricing_pool)
|
||||||
|
result = ProductPublic.model_validate(product, from_attributes=True)
|
||||||
|
return _with_pricing(result, pricing_outputs.get(product.id, (None, None, None)))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{product_id}", status_code=204)
|
@router.delete("/{product_id}", status_code=204)
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,8 @@ class ProductBase(SQLModel):
|
||||||
absorption_speed: AbsorptionSpeed | None = None
|
absorption_speed: AbsorptionSpeed | None = None
|
||||||
leave_on: bool
|
leave_on: bool
|
||||||
|
|
||||||
price_tier: PriceTier | None = None
|
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)
|
size_ml: float | None = Field(default=None, gt=0)
|
||||||
full_weight_g: 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)
|
empty_weight_g: float | None = Field(default=None, gt=0)
|
||||||
|
|
@ -145,9 +146,6 @@ class Product(ProductBase, table=True):
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
|
||||||
# Override: add index for table context
|
|
||||||
price_tier: PriceTier | None = Field(default=None, index=True)
|
|
||||||
|
|
||||||
# Override 9 JSON fields with sa_column (only in table model)
|
# Override 9 JSON fields with sa_column (only in table model)
|
||||||
inci: list[str] = Field(
|
inci: list[str] = Field(
|
||||||
default_factory=list, sa_column=Column(JSON, nullable=False)
|
default_factory=list, sa_column=Column(JSON, nullable=False)
|
||||||
|
|
@ -217,9 +215,17 @@ class Product(ProductBase, table=True):
|
||||||
if self.is_medication and not self.usage_notes:
|
if self.is_medication and not self.usage_notes:
|
||||||
raise ValueError("Medication products must have usage_notes")
|
raise ValueError("Medication products must have usage_notes")
|
||||||
|
|
||||||
|
if self.price_currency is not None:
|
||||||
|
self.price_currency = self.price_currency.upper()
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def to_llm_context(self) -> dict:
|
def to_llm_context(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
computed_price_tier: PriceTier | None = None,
|
||||||
|
price_per_use_pln: float | None = None,
|
||||||
|
) -> dict:
|
||||||
ctx: dict = {
|
ctx: dict = {
|
||||||
"id": str(self.id),
|
"id": str(self.id),
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
|
|
@ -238,8 +244,14 @@ class Product(ProductBase, table=True):
|
||||||
ctx["texture"] = _ev(self.texture)
|
ctx["texture"] = _ev(self.texture)
|
||||||
if self.absorption_speed is not None:
|
if self.absorption_speed is not None:
|
||||||
ctx["absorption_speed"] = _ev(self.absorption_speed)
|
ctx["absorption_speed"] = _ev(self.absorption_speed)
|
||||||
if self.price_tier is not None:
|
if self.price_amount is not None:
|
||||||
ctx["price_tier"] = _ev(self.price_tier)
|
ctx["price_amount"] = self.price_amount
|
||||||
|
if self.price_currency is not None:
|
||||||
|
ctx["price_currency"] = self.price_currency
|
||||||
|
if computed_price_tier is not None:
|
||||||
|
ctx["price_tier"] = _ev(computed_price_tier)
|
||||||
|
if price_per_use_pln is not None:
|
||||||
|
ctx["price_per_use_pln"] = round(price_per_use_pln, 4)
|
||||||
if self.size_ml is not None:
|
if self.size_ml is not None:
|
||||||
ctx["size_ml"] = self.size_ml
|
ctx["size_ml"] = self.size_ml
|
||||||
if self.pao_months is not None:
|
if self.pao_months is not None:
|
||||||
|
|
@ -370,6 +382,9 @@ class ProductPublic(ProductBase):
|
||||||
id: UUID
|
id: UUID
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
price_tier: PriceTier | None = None
|
||||||
|
price_per_use_pln: float | None = None
|
||||||
|
price_tier_source: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProductWithInventory(ProductPublic):
|
class ProductWithInventory(ProductPublic):
|
||||||
|
|
|
||||||
0
backend/innercontext/services/__init__.py
Normal file
0
backend/innercontext/services/__init__.py
Normal file
77
backend/innercontext/services/fx.py
Normal file
77
backend/innercontext/services/fx.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from threading import Lock
|
||||||
|
from urllib.error import URLError
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
NBP_TABLE_A_URL = "https://api.nbp.pl/api/exchangerates/tables/A"
|
||||||
|
_CACHE_TTL = timedelta(hours=24)
|
||||||
|
|
||||||
|
_cache_lock = Lock()
|
||||||
|
_cached_rates: dict[str, float] | None = None
|
||||||
|
_cached_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _now_utc() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_is_fresh() -> bool:
|
||||||
|
if _cached_rates is None or _cached_at is None:
|
||||||
|
return False
|
||||||
|
return _now_utc() - _cached_at < _CACHE_TTL
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_rates_from_nbp() -> dict[str, float]:
|
||||||
|
req = Request(NBP_TABLE_A_URL, headers={"Accept": "application/json"})
|
||||||
|
with urlopen(req, timeout=10) as response:
|
||||||
|
payload = json.loads(response.read().decode("utf-8"))
|
||||||
|
|
||||||
|
if not isinstance(payload, list) or not payload:
|
||||||
|
raise ValueError("Unexpected NBP payload")
|
||||||
|
|
||||||
|
table = payload[0]
|
||||||
|
rates = table.get("rates") if isinstance(table, dict) else None
|
||||||
|
if not isinstance(rates, list):
|
||||||
|
raise ValueError("NBP payload does not include rates")
|
||||||
|
|
||||||
|
parsed: dict[str, float] = {"PLN": 1.0}
|
||||||
|
for row in rates:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
code = row.get("code")
|
||||||
|
mid = row.get("mid")
|
||||||
|
if not isinstance(code, str) or not isinstance(mid, (int, float)):
|
||||||
|
continue
|
||||||
|
parsed[code.upper()] = float(mid)
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def get_pln_rates() -> dict[str, float]:
|
||||||
|
global _cached_rates, _cached_at
|
||||||
|
|
||||||
|
with _cache_lock:
|
||||||
|
if _cache_is_fresh() and _cached_rates is not None:
|
||||||
|
return dict(_cached_rates)
|
||||||
|
|
||||||
|
try:
|
||||||
|
fresh = _fetch_rates_from_nbp()
|
||||||
|
except (URLError, TimeoutError, ValueError):
|
||||||
|
if _cached_rates is not None:
|
||||||
|
return dict(_cached_rates)
|
||||||
|
return {"PLN": 1.0}
|
||||||
|
|
||||||
|
_cached_rates = fresh
|
||||||
|
_cached_at = _now_utc()
|
||||||
|
return dict(fresh)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_pln(amount: float, currency: str) -> float | None:
|
||||||
|
if amount <= 0:
|
||||||
|
return None
|
||||||
|
rates = get_pln_rates()
|
||||||
|
rate = rates.get(currency.upper())
|
||||||
|
if rate is None:
|
||||||
|
return None
|
||||||
|
return amount * rate
|
||||||
177
backend/tests/test_products_pricing.py
Normal file
177
backend/tests/test_products_pricing.py
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from innercontext.api import products as products_api
|
||||||
|
from innercontext.models import Product
|
||||||
|
from innercontext.models.enums import DayTime, ProductCategory
|
||||||
|
|
||||||
|
|
||||||
|
def _product(
|
||||||
|
*, category: ProductCategory, price_amount: float, size_ml: float
|
||||||
|
) -> Product:
|
||||||
|
return Product(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name=f"{category}-{price_amount}",
|
||||||
|
brand="Brand",
|
||||||
|
category=category,
|
||||||
|
recommended_time=DayTime.BOTH,
|
||||||
|
leave_on=True,
|
||||||
|
price_amount=price_amount,
|
||||||
|
price_currency="PLN",
|
||||||
|
size_ml=size_ml,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_pricing_outputs_groups_by_category(monkeypatch):
|
||||||
|
monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount)
|
||||||
|
|
||||||
|
serums = [
|
||||||
|
_product(category=ProductCategory.SERUM, price_amount=float(i), size_ml=35.0)
|
||||||
|
for i in range(10, 90, 10)
|
||||||
|
]
|
||||||
|
cleansers = [
|
||||||
|
_product(
|
||||||
|
category=ProductCategory.CLEANSER, price_amount=float(i), size_ml=200.0
|
||||||
|
)
|
||||||
|
for i in range(40, 120, 10)
|
||||||
|
]
|
||||||
|
outputs = products_api._compute_pricing_outputs(serums + cleansers)
|
||||||
|
|
||||||
|
serum_tiers = [outputs[p.id][0] for p in serums]
|
||||||
|
cleanser_tiers = [outputs[p.id][0] for p in cleansers]
|
||||||
|
|
||||||
|
assert serum_tiers[0] == "budget"
|
||||||
|
assert serum_tiers[-1] == "luxury"
|
||||||
|
assert cleanser_tiers[0] == "budget"
|
||||||
|
assert cleanser_tiers[-1] == "luxury"
|
||||||
|
|
||||||
|
|
||||||
|
def test_price_tier_is_null_when_not_enough_products(client, monkeypatch):
|
||||||
|
monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount)
|
||||||
|
|
||||||
|
base = {
|
||||||
|
"brand": "B",
|
||||||
|
"recommended_time": "both",
|
||||||
|
"leave_on": True,
|
||||||
|
"size_ml": 35.0,
|
||||||
|
"price_currency": "PLN",
|
||||||
|
}
|
||||||
|
for i in range(7):
|
||||||
|
response = client.post(
|
||||||
|
"/products",
|
||||||
|
json={
|
||||||
|
**base,
|
||||||
|
"name": f"Serum {i}",
|
||||||
|
"category": "serum",
|
||||||
|
"price_amount": 30 + i,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
products = client.get("/products").json()
|
||||||
|
assert len(products) == 7
|
||||||
|
assert all(p["price_tier"] is None for p in products)
|
||||||
|
assert all(p["price_per_use_pln"] is not None for p in products)
|
||||||
|
|
||||||
|
|
||||||
|
def test_price_tier_is_computed_on_list(client, monkeypatch):
|
||||||
|
monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount)
|
||||||
|
|
||||||
|
base = {
|
||||||
|
"brand": "B",
|
||||||
|
"recommended_time": "both",
|
||||||
|
"leave_on": True,
|
||||||
|
"size_ml": 35.0,
|
||||||
|
"price_currency": "PLN",
|
||||||
|
"category": "serum",
|
||||||
|
}
|
||||||
|
for i in range(8):
|
||||||
|
response = client.post(
|
||||||
|
"/products",
|
||||||
|
json={**base, "name": f"Serum {i}", "price_amount": 20 + i * 10},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
products = client.get("/products").json()
|
||||||
|
assert len(products) == 8
|
||||||
|
assert any(p["price_tier"] == "budget" for p in products)
|
||||||
|
assert any(p["price_tier"] == "luxury" for p in products)
|
||||||
|
|
||||||
|
|
||||||
|
def test_price_tier_uses_fallback_for_medium_categories(client, monkeypatch):
|
||||||
|
monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount)
|
||||||
|
|
||||||
|
serum_base = {
|
||||||
|
"brand": "B",
|
||||||
|
"recommended_time": "both",
|
||||||
|
"leave_on": True,
|
||||||
|
"size_ml": 35.0,
|
||||||
|
"price_currency": "PLN",
|
||||||
|
"category": "serum",
|
||||||
|
}
|
||||||
|
for i in range(8):
|
||||||
|
response = client.post(
|
||||||
|
"/products",
|
||||||
|
json={**serum_base, "name": f"Serum {i}", "price_amount": 20 + i * 5},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
toner_base = {
|
||||||
|
"brand": "B",
|
||||||
|
"recommended_time": "both",
|
||||||
|
"leave_on": True,
|
||||||
|
"size_ml": 100.0,
|
||||||
|
"price_currency": "PLN",
|
||||||
|
"category": "toner",
|
||||||
|
}
|
||||||
|
for i in range(5):
|
||||||
|
response = client.post(
|
||||||
|
"/products",
|
||||||
|
json={**toner_base, "name": f"Toner {i}", "price_amount": 10 + i * 5},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
products = client.get("/products?category=toner").json()
|
||||||
|
assert len(products) == 5
|
||||||
|
assert all(p["price_tier"] is not None for p in products)
|
||||||
|
assert all(p["price_tier_source"] == "fallback" for p in products)
|
||||||
|
|
||||||
|
|
||||||
|
def test_price_tier_stays_null_for_tiny_categories_even_with_fallback_pool(
|
||||||
|
client, monkeypatch
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount)
|
||||||
|
|
||||||
|
serum_base = {
|
||||||
|
"brand": "B",
|
||||||
|
"recommended_time": "both",
|
||||||
|
"leave_on": True,
|
||||||
|
"size_ml": 35.0,
|
||||||
|
"price_currency": "PLN",
|
||||||
|
"category": "serum",
|
||||||
|
}
|
||||||
|
for i in range(8):
|
||||||
|
response = client.post(
|
||||||
|
"/products",
|
||||||
|
json={**serum_base, "name": f"Serum {i}", "price_amount": 20 + i * 5},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
oil_base = {
|
||||||
|
"brand": "B",
|
||||||
|
"recommended_time": "both",
|
||||||
|
"leave_on": True,
|
||||||
|
"size_ml": 30.0,
|
||||||
|
"price_currency": "PLN",
|
||||||
|
"category": "oil",
|
||||||
|
}
|
||||||
|
for i in range(3):
|
||||||
|
response = client.post(
|
||||||
|
"/products",
|
||||||
|
json={**oil_base, "name": f"Oil {i}", "price_amount": 30 + i * 10},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
oils = client.get("/products?category=oil").json()
|
||||||
|
assert len(oils) == 3
|
||||||
|
assert all(p["price_tier"] is None for p in oils)
|
||||||
|
assert all(p["price_tier_source"] == "insufficient_data" for p in oils)
|
||||||
|
|
@ -108,7 +108,8 @@ export interface ProductParseResponse {
|
||||||
texture?: string;
|
texture?: string;
|
||||||
absorption_speed?: string;
|
absorption_speed?: string;
|
||||||
leave_on?: boolean;
|
leave_on?: boolean;
|
||||||
price_tier?: string;
|
price_amount?: number;
|
||||||
|
price_currency?: string;
|
||||||
size_ml?: number;
|
size_ml?: number;
|
||||||
full_weight_g?: number;
|
full_weight_g?: number;
|
||||||
empty_weight_g?: number;
|
empty_weight_g?: number;
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@
|
||||||
];
|
];
|
||||||
const textures = ['watery', 'gel', 'emulsion', 'cream', 'oil', 'balm', 'foam', 'fluid'];
|
const textures = ['watery', 'gel', 'emulsion', 'cream', 'oil', 'balm', 'foam', 'fluid'];
|
||||||
const absorptionSpeeds = ['very_fast', 'fast', 'moderate', 'slow', 'very_slow'];
|
const absorptionSpeeds = ['very_fast', 'fast', 'moderate', 'slow', 'very_slow'];
|
||||||
const priceTiers = ['budget', 'mid', 'premium', 'luxury'];
|
|
||||||
const skinTypes = ['dry', 'oily', 'combination', 'sensitive', 'normal', 'acne_prone'];
|
const skinTypes = ['dry', 'oily', 'combination', 'sensitive', 'normal', 'acne_prone'];
|
||||||
const skinConcerns = [
|
const skinConcerns = [
|
||||||
'acne', 'rosacea', 'hyperpigmentation', 'aging', 'dehydration',
|
'acne', 'rosacea', 'hyperpigmentation', 'aging', 'dehydration',
|
||||||
|
|
@ -71,13 +70,6 @@
|
||||||
very_slow: m["productForm_absorptionVerySlow"]()
|
very_slow: m["productForm_absorptionVerySlow"]()
|
||||||
});
|
});
|
||||||
|
|
||||||
const priceTierLabels = $derived<Record<string, string>>({
|
|
||||||
budget: m["productForm_priceBudget"](),
|
|
||||||
mid: m["productForm_priceMid"](),
|
|
||||||
premium: m["productForm_pricePremium"](),
|
|
||||||
luxury: m["productForm_priceLuxury"]()
|
|
||||||
});
|
|
||||||
|
|
||||||
const skinTypeLabels = $derived<Record<string, string>>({
|
const skinTypeLabels = $derived<Record<string, string>>({
|
||||||
dry: m["productForm_skinTypeDry"](),
|
dry: m["productForm_skinTypeDry"](),
|
||||||
oily: m["productForm_skinTypeOily"](),
|
oily: m["productForm_skinTypeOily"](),
|
||||||
|
|
@ -210,7 +202,8 @@
|
||||||
if (r.recommended_time) recommendedTime = r.recommended_time;
|
if (r.recommended_time) recommendedTime = r.recommended_time;
|
||||||
if (r.texture) texture = r.texture;
|
if (r.texture) texture = r.texture;
|
||||||
if (r.absorption_speed) absorptionSpeed = r.absorption_speed;
|
if (r.absorption_speed) absorptionSpeed = r.absorption_speed;
|
||||||
if (r.price_tier) priceTier = r.price_tier;
|
if (r.price_amount != null) priceAmount = String(r.price_amount);
|
||||||
|
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.full_weight_g != null) fullWeightG = String(r.full_weight_g);
|
||||||
|
|
@ -260,7 +253,8 @@
|
||||||
let leaveOn = $state(untrack(() => (product?.leave_on != null ? String(product.leave_on) : 'true')));
|
let leaveOn = $state(untrack(() => (product?.leave_on != null ? String(product.leave_on) : 'true')));
|
||||||
let texture = $state(untrack(() => product?.texture ?? ''));
|
let texture = $state(untrack(() => product?.texture ?? ''));
|
||||||
let absorptionSpeed = $state(untrack(() => product?.absorption_speed ?? ''));
|
let absorptionSpeed = $state(untrack(() => product?.absorption_speed ?? ''));
|
||||||
let priceTier = $state(untrack(() => product?.price_tier ?? ''));
|
let priceAmount = $state(untrack(() => (product?.price_amount != null ? String(product.price_amount) : '')));
|
||||||
|
let priceCurrency = $state(untrack(() => product?.price_currency ?? 'PLN'));
|
||||||
let fragranceFree = $state(
|
let fragranceFree = $state(
|
||||||
untrack(() => (product?.fragrance_free != null ? String(product.fragrance_free) : ''))
|
untrack(() => (product?.fragrance_free != null ? String(product.fragrance_free) : ''))
|
||||||
);
|
);
|
||||||
|
|
@ -776,16 +770,13 @@
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>{m["productForm_priceTier"]()}</Label>
|
<Label for="price_amount">Price</Label>
|
||||||
<input type="hidden" name="price_tier" value={priceTier} />
|
<Input id="price_amount" name="price_amount" type="number" min="0" step="0.01" placeholder="e.g. 79.99" bind:value={priceAmount} />
|
||||||
<Select type="single" value={priceTier} onValueChange={(v) => (priceTier = v)}>
|
</div>
|
||||||
<SelectTrigger>{priceTier ? priceTierLabels[priceTier] : m["productForm_selectTier"]()}</SelectTrigger>
|
|
||||||
<SelectContent>
|
<div class="space-y-2">
|
||||||
{#each priceTiers as p}
|
<Label for="price_currency">Currency</Label>
|
||||||
<SelectItem value={p}>{priceTierLabels[p]}</SelectItem>
|
<Input id="price_currency" name="price_currency" maxlength={3} placeholder="PLN" bind:value={priceCurrency} />
|
||||||
{/each}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export type MedicationKind =
|
||||||
export type OverallSkinState = "excellent" | "good" | "fair" | "poor";
|
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 ProductCategory =
|
export type ProductCategory =
|
||||||
| "cleanser"
|
| "cleanser"
|
||||||
| "toner"
|
| "toner"
|
||||||
|
|
@ -147,7 +148,11 @@ export interface Product {
|
||||||
texture?: TextureType;
|
texture?: TextureType;
|
||||||
absorption_speed?: AbsorptionSpeed;
|
absorption_speed?: AbsorptionSpeed;
|
||||||
leave_on: boolean;
|
leave_on: boolean;
|
||||||
|
price_amount?: number;
|
||||||
|
price_currency?: string;
|
||||||
price_tier?: PriceTier;
|
price_tier?: PriceTier;
|
||||||
|
price_per_use_pln?: number;
|
||||||
|
price_tier_source?: PriceTierSource;
|
||||||
size_ml?: number;
|
size_ml?: number;
|
||||||
full_weight_g?: number;
|
full_weight_g?: number;
|
||||||
empty_weight_g?: number;
|
empty_weight_g?: number;
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,31 @@
|
||||||
})());
|
})());
|
||||||
|
|
||||||
const totalCount = $derived(groupedProducts.reduce((s, [, arr]) => s + arr.length, 0));
|
const totalCount = $derived(groupedProducts.reduce((s, [, arr]) => s + arr.length, 0));
|
||||||
|
|
||||||
|
function formatPricePerUse(value?: number): string {
|
||||||
|
if (value == null) return '-';
|
||||||
|
return `${value.toFixed(2)} PLN/use`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTier(value?: string): string {
|
||||||
|
if (!value) return 'n/a';
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPricePerUse(product: Product): number | undefined {
|
||||||
|
return (product as Product & { price_per_use_pln?: number }).price_per_use_pln;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTierSource(product: Product): string | undefined {
|
||||||
|
return (product as Product & { price_tier_source?: string }).price_tier_source;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceLabel(product: Product): string {
|
||||||
|
const source = getTierSource(product);
|
||||||
|
if (source === 'fallback') return 'fallback';
|
||||||
|
if (source === 'insufficient_data') return 'insufficient';
|
||||||
|
return 'category';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{m.products_title()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m.products_title()} — innercontext</title></svelte:head>
|
||||||
|
|
@ -92,26 +117,27 @@
|
||||||
<TableHead>{m["products_colBrand"]()}</TableHead>
|
<TableHead>{m["products_colBrand"]()}</TableHead>
|
||||||
<TableHead>{m["products_colTargets"]()}</TableHead>
|
<TableHead>{m["products_colTargets"]()}</TableHead>
|
||||||
<TableHead>{m["products_colTime"]()}</TableHead>
|
<TableHead>{m["products_colTime"]()}</TableHead>
|
||||||
|
<TableHead>Pricing</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{#if totalCount === 0}
|
{#if totalCount === 0}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colspan={4} class="text-center text-muted-foreground py-8">
|
<TableCell colspan={5} class="text-center text-muted-foreground py-8">
|
||||||
{m["products_noProducts"]()}
|
{m["products_noProducts"]()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{:else}
|
{:else}
|
||||||
{#each groupedProducts as [category, products] (category)}
|
{#each groupedProducts as [category, products] (category)}
|
||||||
<TableRow class="bg-muted/30 hover:bg-muted/30">
|
<TableRow class="bg-muted/30 hover:bg-muted/30">
|
||||||
<TableCell colspan={4} class="font-semibold text-sm py-2 text-muted-foreground uppercase tracking-wide">
|
<TableCell colspan={5} class="font-semibold text-sm py-2 text-muted-foreground uppercase tracking-wide">
|
||||||
{category.replace(/_/g, ' ')}
|
{category.replace(/_/g, ' ')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{#each products as product (product.id)}
|
{#each products as product (product.id)}
|
||||||
<TableRow class="cursor-pointer hover:bg-muted/50">
|
<TableRow class="cursor-pointer hover:bg-muted/50">
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<a href="/products/{product.id}" class="font-medium hover:underline">
|
<a href={resolve(`/products/${product.id}`)} class="font-medium hover:underline">
|
||||||
{product.name}
|
{product.name}
|
||||||
</a>
|
</a>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -127,6 +153,13 @@
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="uppercase text-sm">{product.recommended_time}</TableCell>
|
<TableCell class="uppercase text-sm">{product.recommended_time}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="text-muted-foreground">{formatPricePerUse(getPricePerUse(product))}</span>
|
||||||
|
<Badge variant="outline" class="uppercase text-[10px]">{formatTier(product.price_tier)}</Badge>
|
||||||
|
<Badge variant="secondary" class="text-[10px]">{sourceLabel(product)}</Badge>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -146,13 +179,18 @@
|
||||||
</div>
|
</div>
|
||||||
{#each products as product (product.id)}
|
{#each products as product (product.id)}
|
||||||
<a
|
<a
|
||||||
href="/products/{product.id}"
|
href={resolve(`/products/${product.id}`)}
|
||||||
class="block rounded-lg border border-border p-4 hover:bg-muted/50"
|
class="block rounded-lg border border-border p-4 hover:bg-muted/50"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium">{product.name}</p>
|
<p class="font-medium">{product.name}</p>
|
||||||
<p class="text-sm text-muted-foreground">{product.brand}</p>
|
<p class="text-sm text-muted-foreground">{product.brand}</p>
|
||||||
|
<div class="mt-1 flex items-center gap-2 text-xs">
|
||||||
|
<span class="text-muted-foreground">{formatPricePerUse(getPricePerUse(product))}</span>
|
||||||
|
<Badge variant="outline" class="uppercase text-[10px]">{formatTier(product.price_tier)}</Badge>
|
||||||
|
<Badge variant="secondary" class="text-[10px]">{sourceLabel(product)}</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="shrink-0 text-xs uppercase text-muted-foreground">{product.recommended_time}</span>
|
<span class="shrink-0 text-xs uppercase text-muted-foreground">{product.recommended_time}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -119,17 +119,19 @@ export const actions: Actions = {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Optional strings
|
// Optional strings
|
||||||
for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes']) {
|
for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes', 'price_currency']) {
|
||||||
const v = parseOptionalString(form.get(field) as string | null);
|
const v = parseOptionalString(form.get(field) as string | null);
|
||||||
body[field] = v ?? null;
|
body[field] = v ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional enum selects (null if empty = clearing the value)
|
// Optional enum selects (null if empty = clearing the value)
|
||||||
for (const field of ['texture', 'absorption_speed', 'price_tier']) {
|
for (const field of ['texture', 'absorption_speed']) {
|
||||||
const v = form.get(field) as string | null;
|
const v = form.get(field) as string | null;
|
||||||
body[field] = v || null;
|
body[field] = v || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.price_amount = parseOptionalFloat(form.get('price_amount') as string | null) ?? null;
|
||||||
|
|
||||||
// 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.full_weight_g = parseOptionalFloat(form.get('full_weight_g') as string | null) ?? null;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,43 @@
|
||||||
|
|
||||||
let showInventoryForm = $state(false);
|
let showInventoryForm = $state(false);
|
||||||
let editingInventoryId = $state<string | null>(null);
|
let editingInventoryId = $state<string | null>(null);
|
||||||
|
|
||||||
|
function formatAmount(amount?: number, currency?: string): string {
|
||||||
|
if (amount == null || !currency) return '-';
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat('pl-PL', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency.toUpperCase(),
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
}).format(amount);
|
||||||
|
} catch {
|
||||||
|
return `${amount.toFixed(2)} ${currency.toUpperCase()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPricePerUse(value?: number): string {
|
||||||
|
if (value == null) return '-';
|
||||||
|
return `${value.toFixed(2)} PLN/use`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriceAmount(): number | undefined {
|
||||||
|
return (product as { price_amount?: number }).price_amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriceCurrency(): string | undefined {
|
||||||
|
return (product as { price_currency?: string }).price_currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPricePerUse(): number | undefined {
|
||||||
|
return (product as { price_per_use_pln?: number }).price_per_use_pln;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTierSource(): string {
|
||||||
|
const source = (product as { price_tier_source?: string }).price_tier_source;
|
||||||
|
if (source === 'fallback') return 'fallback';
|
||||||
|
if (source === 'insufficient_data') return 'insufficient_data';
|
||||||
|
return 'category';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
|
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
|
||||||
|
|
@ -33,6 +70,26 @@
|
||||||
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m.common_saved()}</div>
|
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m.common_saved()}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent class="pt-4">
|
||||||
|
<div class="grid grid-cols-1 gap-3 text-sm sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground">Price</p>
|
||||||
|
<p class="font-medium">{formatAmount(getPriceAmount(), getPriceCurrency())}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground">Price per use</p>
|
||||||
|
<p class="font-medium">{formatPricePerUse(getPricePerUse())}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground">Price tier</p>
|
||||||
|
<p class="font-medium uppercase">{product.price_tier ?? 'n/a'}</p>
|
||||||
|
<p class="text-xs text-muted-foreground">source: {getTierSource()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<!-- Edit form -->
|
<!-- Edit form -->
|
||||||
<form method="POST" action="?/update" use:enhance class="space-y-6">
|
<form method="POST" action="?/update" use:enhance class="space-y-6">
|
||||||
<ProductForm {product} />
|
<ProductForm {product} />
|
||||||
|
|
|
||||||
|
|
@ -107,17 +107,20 @@ export const actions: Actions = {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Optional strings
|
// Optional strings
|
||||||
for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes']) {
|
for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes', 'price_currency']) {
|
||||||
const v = parseOptionalString(form.get(field) as string | null);
|
const v = parseOptionalString(form.get(field) as string | null);
|
||||||
if (v !== undefined) payload[field] = v;
|
if (v !== undefined) payload[field] = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional enum selects
|
// Optional enum selects
|
||||||
for (const field of ['texture', 'absorption_speed', 'price_tier']) {
|
for (const field of ['texture', 'absorption_speed']) {
|
||||||
const v = form.get(field) as string | null;
|
const v = form.get(field) as string | null;
|
||||||
if (v) payload[field] = v;
|
if (v) payload[field] = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const price_amount = parseOptionalFloat(form.get('price_amount') as string | null);
|
||||||
|
if (price_amount !== undefined) payload.price_amount = price_amount;
|
||||||
|
|
||||||
// Optional numbers
|
// Optional numbers
|
||||||
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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue