feat(backend): move product pricing to async persisted jobs
This commit is contained in:
parent
c869f88db2
commit
0e439b4ca7
18 changed files with 468 additions and 67 deletions
|
|
@ -0,0 +1,85 @@
|
||||||
|
"""add_async_pricing_jobs_and_snapshot_fields
|
||||||
|
|
||||||
|
Revision ID: f1a2b3c4d5e6
|
||||||
|
Revises: 7c91e4b2af38
|
||||||
|
Create Date: 2026-03-04 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel.sql.sqltypes
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "f1a2b3c4d5e6"
|
||||||
|
down_revision: Union[str, None] = "7c91e4b2af38"
|
||||||
|
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_tier",
|
||||||
|
sa.Enum("BUDGET", "MID", "PREMIUM", "LUXURY", name="pricetier"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column("products", sa.Column("price_per_use_pln", sa.Float(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"products", sa.Column("price_tier_source", sa.String(length=32), nullable=True)
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"products", sa.Column("pricing_computed_at", sa.DateTime(), nullable=True)
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_products_price_tier"), "products", ["price_tier"], unique=False
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"pricing_recalc_jobs",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("scope", sqlmodel.sql.sqltypes.AutoString(length=32), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"status", sqlmodel.sql.sqltypes.AutoString(length=16), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column("attempts", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("error", sqlmodel.sql.sqltypes.AutoString(length=512), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("started_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("finished_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_pricing_recalc_jobs_scope"),
|
||||||
|
"pricing_recalc_jobs",
|
||||||
|
["scope"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_pricing_recalc_jobs_status"),
|
||||||
|
"pricing_recalc_jobs",
|
||||||
|
["status"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_pricing_recalc_jobs_status"), table_name="pricing_recalc_jobs"
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_pricing_recalc_jobs_scope"), table_name="pricing_recalc_jobs"
|
||||||
|
)
|
||||||
|
op.drop_table("pricing_recalc_jobs")
|
||||||
|
|
||||||
|
op.drop_index(op.f("ix_products_price_tier"), table_name="products")
|
||||||
|
op.drop_column("products", "pricing_computed_at")
|
||||||
|
op.drop_column("products", "price_tier_source")
|
||||||
|
op.drop_column("products", "price_per_use_pln")
|
||||||
|
op.drop_column("products", "price_tier")
|
||||||
|
op.execute("DROP TYPE IF EXISTS pricetier")
|
||||||
|
|
@ -7,7 +7,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from google.genai import types as genai_types
|
from google.genai import types as genai_types
|
||||||
from pydantic import BaseModel as PydanticBase
|
from pydantic import BaseModel as PydanticBase
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from sqlmodel import Session, SQLModel, col, select
|
from sqlalchemy import inspect, select as sa_select
|
||||||
|
from sqlmodel import Field, Session, SQLModel, col, select
|
||||||
|
|
||||||
from db import get_session
|
from db import get_session
|
||||||
from innercontext.api.utils import get_or_404
|
from innercontext.api.utils import get_or_404
|
||||||
|
|
@ -18,6 +19,7 @@ from innercontext.llm import (
|
||||||
get_extraction_config,
|
get_extraction_config,
|
||||||
)
|
)
|
||||||
from innercontext.services.fx import convert_to_pln
|
from innercontext.services.fx import convert_to_pln
|
||||||
|
from innercontext.services.pricing_jobs import enqueue_pricing_recalc
|
||||||
from innercontext.models import (
|
from innercontext.models import (
|
||||||
Product,
|
Product,
|
||||||
ProductBase,
|
ProductBase,
|
||||||
|
|
@ -43,6 +45,10 @@ from innercontext.models.product import (
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
PricingSource = Literal["category", "fallback", "insufficient_data"]
|
||||||
|
PricingOutput = tuple[PriceTier | None, float | None, PricingSource | None]
|
||||||
|
PricingOutputs = dict[UUID, PricingOutput]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Request / response schemas
|
# Request / response schemas
|
||||||
|
|
@ -150,6 +156,19 @@ class ProductParseResponse(SQLModel):
|
||||||
needle_length_mm: Optional[float] = None
|
needle_length_mm: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProductListItem(SQLModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
brand: str
|
||||||
|
category: ProductCategory
|
||||||
|
recommended_time: DayTime
|
||||||
|
targets: list[SkinConcern] = Field(default_factory=list)
|
||||||
|
is_owned: bool
|
||||||
|
price_tier: PriceTier | None = None
|
||||||
|
price_per_use_pln: float | None = None
|
||||||
|
price_tier_source: PricingSource | None = None
|
||||||
|
|
||||||
|
|
||||||
class AIActiveIngredient(ActiveIngredient):
|
class AIActiveIngredient(ActiveIngredient):
|
||||||
# Gemini API rejects int-enum values in response_schema; override with plain int.
|
# Gemini API rejects int-enum values in response_schema; override with plain int.
|
||||||
strength_level: Optional[int] = None # type: ignore[assignment]
|
strength_level: Optional[int] = None # type: ignore[assignment]
|
||||||
|
|
@ -317,14 +336,7 @@ def _thresholds(values: list[float]) -> tuple[float, float, float]:
|
||||||
|
|
||||||
def _compute_pricing_outputs(
|
def _compute_pricing_outputs(
|
||||||
products: list[Product],
|
products: list[Product],
|
||||||
) -> dict[
|
) -> PricingOutputs:
|
||||||
UUID,
|
|
||||||
tuple[
|
|
||||||
PriceTier | None,
|
|
||||||
float | None,
|
|
||||||
Literal["category", "fallback", "insufficient_data"] | None,
|
|
||||||
],
|
|
||||||
]:
|
|
||||||
price_per_use_by_id: dict[UUID, float] = {}
|
price_per_use_by_id: dict[UUID, float] = {}
|
||||||
grouped: dict[ProductCategory, list[tuple[UUID, float]]] = {}
|
grouped: dict[ProductCategory, list[tuple[UUID, float]]] = {}
|
||||||
|
|
||||||
|
|
@ -335,14 +347,7 @@ def _compute_pricing_outputs(
|
||||||
price_per_use_by_id[product.id] = ppu
|
price_per_use_by_id[product.id] = ppu
|
||||||
grouped.setdefault(product.category, []).append((product.id, ppu))
|
grouped.setdefault(product.category, []).append((product.id, ppu))
|
||||||
|
|
||||||
outputs: dict[
|
outputs: PricingOutputs = {
|
||||||
UUID,
|
|
||||||
tuple[
|
|
||||||
PriceTier | None,
|
|
||||||
float | None,
|
|
||||||
Literal["category", "fallback", "insufficient_data"] | None,
|
|
||||||
],
|
|
||||||
] = {
|
|
||||||
p.id: (
|
p.id: (
|
||||||
None,
|
None,
|
||||||
price_per_use_by_id.get(p.id),
|
price_per_use_by_id.get(p.id),
|
||||||
|
|
@ -385,21 +390,6 @@ def _compute_pricing_outputs(
|
||||||
return outputs
|
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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -424,7 +414,7 @@ def list_products(
|
||||||
if is_tool is not None:
|
if is_tool is not None:
|
||||||
stmt = stmt.where(Product.is_tool == is_tool)
|
stmt = stmt.where(Product.is_tool == is_tool)
|
||||||
|
|
||||||
products = session.exec(stmt).all()
|
products = list(session.exec(stmt).all())
|
||||||
|
|
||||||
# Filter by targets (JSON column — done in Python)
|
# Filter by targets (JSON column — done in Python)
|
||||||
if targets:
|
if targets:
|
||||||
|
|
@ -454,12 +444,8 @@ 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
|
||||||
|
|
@ -476,6 +462,7 @@ def create_product(data: ProductCreate, session: Session = Depends(get_session))
|
||||||
**payload,
|
**payload,
|
||||||
)
|
)
|
||||||
session.add(product)
|
session.add(product)
|
||||||
|
enqueue_pricing_recalc(session)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(product)
|
session.refresh(product)
|
||||||
return product
|
return product
|
||||||
|
|
@ -631,17 +618,104 @@ def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
|
||||||
raise HTTPException(status_code=422, detail=e.errors())
|
raise HTTPException(status_code=422, detail=e.errors())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary", response_model=list[ProductListItem])
|
||||||
|
def list_products_summary(
|
||||||
|
category: Optional[ProductCategory] = None,
|
||||||
|
brand: Optional[str] = None,
|
||||||
|
targets: Optional[list[SkinConcern]] = Query(default=None),
|
||||||
|
is_medication: Optional[bool] = None,
|
||||||
|
is_tool: Optional[bool] = None,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
product_table = inspect(Product).local_table
|
||||||
|
stmt = sa_select(
|
||||||
|
product_table.c.id,
|
||||||
|
product_table.c.name,
|
||||||
|
product_table.c.brand,
|
||||||
|
product_table.c.category,
|
||||||
|
product_table.c.recommended_time,
|
||||||
|
product_table.c.targets,
|
||||||
|
product_table.c.price_tier,
|
||||||
|
product_table.c.price_per_use_pln,
|
||||||
|
product_table.c.price_tier_source,
|
||||||
|
)
|
||||||
|
if category is not None:
|
||||||
|
stmt = stmt.where(product_table.c.category == category)
|
||||||
|
if brand is not None:
|
||||||
|
stmt = stmt.where(product_table.c.brand == brand)
|
||||||
|
if is_medication is not None:
|
||||||
|
stmt = stmt.where(product_table.c.is_medication == is_medication)
|
||||||
|
if is_tool is not None:
|
||||||
|
stmt = stmt.where(product_table.c.is_tool == is_tool)
|
||||||
|
|
||||||
|
rows = list(session.execute(stmt).all())
|
||||||
|
|
||||||
|
if targets:
|
||||||
|
target_values = {t.value for t in targets}
|
||||||
|
rows = [
|
||||||
|
row
|
||||||
|
for row in rows
|
||||||
|
if any(
|
||||||
|
(t.value if hasattr(t, "value") else t) in target_values
|
||||||
|
for t in (row[5] or [])
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
product_ids = [row[0] for row in rows]
|
||||||
|
inventory_rows = (
|
||||||
|
session.exec(
|
||||||
|
select(ProductInventory).where(
|
||||||
|
col(ProductInventory.product_id).in_(product_ids)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
if product_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
owned_ids = {
|
||||||
|
inv.product_id
|
||||||
|
for inv in inventory_rows
|
||||||
|
if inv.product_id is not None and inv.finished_at is None
|
||||||
|
}
|
||||||
|
|
||||||
|
results: list[ProductListItem] = []
|
||||||
|
for row in rows:
|
||||||
|
(
|
||||||
|
product_id,
|
||||||
|
name,
|
||||||
|
brand_value,
|
||||||
|
category_value,
|
||||||
|
recommended_time,
|
||||||
|
row_targets,
|
||||||
|
price_tier,
|
||||||
|
price_per_use_pln,
|
||||||
|
price_tier_source,
|
||||||
|
) = row
|
||||||
|
results.append(
|
||||||
|
ProductListItem(
|
||||||
|
id=product_id,
|
||||||
|
name=name,
|
||||||
|
brand=brand_value,
|
||||||
|
category=category_value,
|
||||||
|
recommended_time=recommended_time,
|
||||||
|
targets=row_targets or [],
|
||||||
|
is_owned=product_id in owned_ids,
|
||||||
|
price_tier=price_tier,
|
||||||
|
price_per_use_pln=price_per_use_pln,
|
||||||
|
price_tier_source=price_tier_source,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
@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
|
||||||
|
|
||||||
|
|
@ -658,18 +732,17 @@ def update_product(
|
||||||
for key, value in patch_data.items():
|
for key, value in patch_data.items():
|
||||||
setattr(product, key, value)
|
setattr(product, key, value)
|
||||||
session.add(product)
|
session.add(product)
|
||||||
|
enqueue_pricing_recalc(session)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(product)
|
session.refresh(product)
|
||||||
pricing_pool = list(session.exec(select(Product)).all())
|
return ProductPublic.model_validate(product, from_attributes=True)
|
||||||
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)
|
||||||
def delete_product(product_id: UUID, session: Session = Depends(get_session)):
|
def delete_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)
|
||||||
session.delete(product)
|
session.delete(product)
|
||||||
|
enqueue_pricing_recalc(session)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ from .product import (
|
||||||
ProductPublic,
|
ProductPublic,
|
||||||
ProductWithInventory,
|
ProductWithInventory,
|
||||||
)
|
)
|
||||||
|
from .pricing import PricingRecalcJob
|
||||||
from .routine import GroomingSchedule, Routine, RoutineStep
|
from .routine import GroomingSchedule, Routine, RoutineStep
|
||||||
from .skincare import (
|
from .skincare import (
|
||||||
SkinConditionSnapshot,
|
SkinConditionSnapshot,
|
||||||
|
|
@ -77,6 +78,7 @@ __all__ = [
|
||||||
"ProductInventory",
|
"ProductInventory",
|
||||||
"ProductPublic",
|
"ProductPublic",
|
||||||
"ProductWithInventory",
|
"ProductWithInventory",
|
||||||
|
"PricingRecalcJob",
|
||||||
# routine
|
# routine
|
||||||
"GroomingSchedule",
|
"GroomingSchedule",
|
||||||
"Routine",
|
"Routine",
|
||||||
|
|
|
||||||
33
backend/innercontext/models/pricing.py
Normal file
33
backend/innercontext/models/pricing.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import ClassVar
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
from .base import utc_now
|
||||||
|
from .domain import Domain
|
||||||
|
|
||||||
|
|
||||||
|
class PricingRecalcJob(SQLModel, table=True):
|
||||||
|
__tablename__ = "pricing_recalc_jobs"
|
||||||
|
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
scope: str = Field(default="global", max_length=32, index=True)
|
||||||
|
status: str = Field(default="pending", max_length=16, index=True)
|
||||||
|
attempts: int = Field(default=0, ge=0)
|
||||||
|
error: str | None = Field(default=None, max_length=512)
|
||||||
|
|
||||||
|
created_at: datetime = Field(default_factory=utc_now, nullable=False)
|
||||||
|
started_at: datetime | None = Field(default=None)
|
||||||
|
finished_at: datetime | None = Field(default=None)
|
||||||
|
updated_at: datetime = Field(
|
||||||
|
default_factory=utc_now,
|
||||||
|
sa_column=Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=utc_now,
|
||||||
|
onupdate=utc_now,
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
@ -174,6 +174,11 @@ class Product(ProductBase, table=True):
|
||||||
default=None, sa_column=Column(JSON, nullable=True)
|
default=None, sa_column=Column(JSON, nullable=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
price_tier: PriceTier | None = Field(default=None, index=True)
|
||||||
|
price_per_use_pln: float | None = Field(default=None)
|
||||||
|
price_tier_source: str | None = Field(default=None, max_length=32)
|
||||||
|
pricing_computed_at: datetime | None = Field(default=None)
|
||||||
|
|
||||||
created_at: datetime = Field(default_factory=utc_now, nullable=False)
|
created_at: datetime = Field(default_factory=utc_now, nullable=False)
|
||||||
updated_at: datetime = Field(
|
updated_at: datetime = Field(
|
||||||
default_factory=utc_now,
|
default_factory=utc_now,
|
||||||
|
|
|
||||||
93
backend/innercontext/services/pricing_jobs.py
Normal file
93
backend/innercontext/services/pricing_jobs.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlmodel import Session, col, select
|
||||||
|
|
||||||
|
from innercontext.models import PricingRecalcJob, Product
|
||||||
|
from innercontext.models.base import utc_now
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_pricing_recalc(
|
||||||
|
session: Session, *, scope: str = "global"
|
||||||
|
) -> PricingRecalcJob:
|
||||||
|
existing = session.exec(
|
||||||
|
select(PricingRecalcJob)
|
||||||
|
.where(PricingRecalcJob.scope == scope)
|
||||||
|
.where(col(PricingRecalcJob.status).in_(["pending", "running"]))
|
||||||
|
.order_by(col(PricingRecalcJob.created_at).asc())
|
||||||
|
).first()
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
job = PricingRecalcJob(scope=scope, status="pending")
|
||||||
|
session.add(job)
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def claim_next_pending_pricing_job(session: Session) -> PricingRecalcJob | None:
|
||||||
|
stmt = (
|
||||||
|
select(PricingRecalcJob)
|
||||||
|
.where(PricingRecalcJob.status == "pending")
|
||||||
|
.order_by(col(PricingRecalcJob.created_at).asc())
|
||||||
|
)
|
||||||
|
bind = session.get_bind()
|
||||||
|
if bind is not None and bind.dialect.name == "postgresql":
|
||||||
|
stmt = stmt.with_for_update(skip_locked=True)
|
||||||
|
|
||||||
|
job = session.exec(stmt).first()
|
||||||
|
if job is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
job.status = "running"
|
||||||
|
job.attempts += 1
|
||||||
|
job.started_at = utc_now()
|
||||||
|
job.finished_at = None
|
||||||
|
job.error = None
|
||||||
|
session.add(job)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(job)
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_pricing_snapshot(session: Session, computed_at: datetime) -> int:
|
||||||
|
from innercontext.api.products import _compute_pricing_outputs
|
||||||
|
|
||||||
|
products = list(session.exec(select(Product)).all())
|
||||||
|
pricing_outputs = _compute_pricing_outputs(products)
|
||||||
|
|
||||||
|
for product in products:
|
||||||
|
tier, price_per_use_pln, tier_source = pricing_outputs.get(
|
||||||
|
product.id, (None, None, None)
|
||||||
|
)
|
||||||
|
product.price_tier = tier
|
||||||
|
product.price_per_use_pln = price_per_use_pln
|
||||||
|
product.price_tier_source = tier_source
|
||||||
|
product.pricing_computed_at = computed_at
|
||||||
|
|
||||||
|
return len(products)
|
||||||
|
|
||||||
|
|
||||||
|
def process_pricing_job(session: Session, job: PricingRecalcJob) -> int:
|
||||||
|
try:
|
||||||
|
updated_count = _apply_pricing_snapshot(session, computed_at=utc_now())
|
||||||
|
job.status = "succeeded"
|
||||||
|
job.finished_at = utc_now()
|
||||||
|
job.error = None
|
||||||
|
session.add(job)
|
||||||
|
session.commit()
|
||||||
|
return updated_count
|
||||||
|
except Exception as exc:
|
||||||
|
session.rollback()
|
||||||
|
job.status = "failed"
|
||||||
|
job.finished_at = utc_now()
|
||||||
|
job.error = str(exc)[:512]
|
||||||
|
session.add(job)
|
||||||
|
session.commit()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def process_one_pending_pricing_job(session: Session) -> bool:
|
||||||
|
job = claim_next_pending_pricing_job(session)
|
||||||
|
if job is None:
|
||||||
|
return False
|
||||||
|
process_pricing_job(session, job)
|
||||||
|
return True
|
||||||
18
backend/innercontext/workers/pricing.py
Normal file
18
backend/innercontext/workers/pricing.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import time
|
||||||
|
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from db import engine
|
||||||
|
from innercontext.services.pricing_jobs import process_one_pending_pricing_job
|
||||||
|
|
||||||
|
|
||||||
|
def run_forever(poll_interval_seconds: float = 2.0) -> None:
|
||||||
|
while True:
|
||||||
|
with Session(engine) as session:
|
||||||
|
processed = process_one_pending_pricing_job(session)
|
||||||
|
if not processed:
|
||||||
|
time.sleep(poll_interval_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_forever()
|
||||||
|
|
@ -7,8 +7,9 @@ load_dotenv() # load .env before db.py reads DATABASE_URL
|
||||||
|
|
||||||
from fastapi import FastAPI # noqa: E402
|
from fastapi import FastAPI # noqa: E402
|
||||||
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
|
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
|
||||||
|
from sqlmodel import Session # noqa: E402
|
||||||
|
|
||||||
from db import create_db_and_tables # noqa: E402
|
from db import create_db_and_tables, engine # noqa: E402
|
||||||
from innercontext.api import ( # noqa: E402
|
from innercontext.api import ( # noqa: E402
|
||||||
ai_logs,
|
ai_logs,
|
||||||
health,
|
health,
|
||||||
|
|
@ -17,11 +18,18 @@ from innercontext.api import ( # noqa: E402
|
||||||
routines,
|
routines,
|
||||||
skincare,
|
skincare,
|
||||||
)
|
)
|
||||||
|
from innercontext.services.pricing_jobs import enqueue_pricing_recalc # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
create_db_and_tables()
|
create_db_and_tables()
|
||||||
|
try:
|
||||||
|
with Session(engine) as session:
|
||||||
|
enqueue_pricing_recalc(session)
|
||||||
|
session.commit()
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
print(f"[startup] failed to enqueue pricing recalculation job: {exc}")
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from innercontext.api import products as products_api
|
from innercontext.api import products as products_api
|
||||||
from innercontext.models import Product
|
from innercontext.models import PricingRecalcJob, Product
|
||||||
from innercontext.models.enums import DayTime, ProductCategory
|
from innercontext.models.enums import DayTime, ProductCategory
|
||||||
|
from innercontext.services.pricing_jobs import process_one_pending_pricing_job
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
|
||||||
def _product(
|
def _product(
|
||||||
|
|
@ -45,7 +47,7 @@ def test_compute_pricing_outputs_groups_by_category(monkeypatch):
|
||||||
assert cleanser_tiers[-1] == "luxury"
|
assert cleanser_tiers[-1] == "luxury"
|
||||||
|
|
||||||
|
|
||||||
def test_price_tier_is_null_when_not_enough_products(client, monkeypatch):
|
def test_price_tier_is_null_when_not_enough_products(client, session, monkeypatch):
|
||||||
monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount)
|
monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount)
|
||||||
|
|
||||||
base = {
|
base = {
|
||||||
|
|
@ -67,13 +69,15 @@ def test_price_tier_is_null_when_not_enough_products(client, monkeypatch):
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
assert process_one_pending_pricing_job(session)
|
||||||
|
|
||||||
products = client.get("/products").json()
|
products = client.get("/products").json()
|
||||||
assert len(products) == 7
|
assert len(products) == 7
|
||||||
assert all(p["price_tier"] is None for p in products)
|
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)
|
assert all(p["price_per_use_pln"] is not None for p in products)
|
||||||
|
|
||||||
|
|
||||||
def test_price_tier_is_computed_on_list(client, monkeypatch):
|
def test_price_tier_is_computed_by_worker(client, session, monkeypatch):
|
||||||
monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount)
|
monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount)
|
||||||
|
|
||||||
base = {
|
base = {
|
||||||
|
|
@ -91,13 +95,15 @@ def test_price_tier_is_computed_on_list(client, monkeypatch):
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
assert process_one_pending_pricing_job(session)
|
||||||
|
|
||||||
products = client.get("/products").json()
|
products = client.get("/products").json()
|
||||||
assert len(products) == 8
|
assert len(products) == 8
|
||||||
assert any(p["price_tier"] == "budget" for p in products)
|
assert any(p["price_tier"] == "budget" for p in products)
|
||||||
assert any(p["price_tier"] == "luxury" 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):
|
def test_price_tier_uses_fallback_for_medium_categories(client, session, monkeypatch):
|
||||||
monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount)
|
monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount)
|
||||||
|
|
||||||
serum_base = {
|
serum_base = {
|
||||||
|
|
@ -130,6 +136,8 @@ def test_price_tier_uses_fallback_for_medium_categories(client, monkeypatch):
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
assert process_one_pending_pricing_job(session)
|
||||||
|
|
||||||
products = client.get("/products?category=toner").json()
|
products = client.get("/products?category=toner").json()
|
||||||
assert len(products) == 5
|
assert len(products) == 5
|
||||||
assert all(p["price_tier"] is not None for p in products)
|
assert all(p["price_tier"] is not None for p in products)
|
||||||
|
|
@ -137,7 +145,7 @@ def test_price_tier_uses_fallback_for_medium_categories(client, monkeypatch):
|
||||||
|
|
||||||
|
|
||||||
def test_price_tier_stays_null_for_tiny_categories_even_with_fallback_pool(
|
def test_price_tier_stays_null_for_tiny_categories_even_with_fallback_pool(
|
||||||
client, monkeypatch
|
client, session, monkeypatch
|
||||||
):
|
):
|
||||||
monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount)
|
monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount)
|
||||||
|
|
||||||
|
|
@ -171,7 +179,27 @@ def test_price_tier_stays_null_for_tiny_categories_even_with_fallback_pool(
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
assert process_one_pending_pricing_job(session)
|
||||||
|
|
||||||
oils = client.get("/products?category=oil").json()
|
oils = client.get("/products?category=oil").json()
|
||||||
assert len(oils) == 3
|
assert len(oils) == 3
|
||||||
assert all(p["price_tier"] is None for p in oils)
|
assert all(p["price_tier"] is None for p in oils)
|
||||||
assert all(p["price_tier_source"] == "insufficient_data" for p in oils)
|
assert all(p["price_tier_source"] == "insufficient_data" for p in oils)
|
||||||
|
|
||||||
|
|
||||||
|
def test_product_write_enqueues_pricing_job(client, session):
|
||||||
|
response = client.post(
|
||||||
|
"/products",
|
||||||
|
json={
|
||||||
|
"name": "Serum X",
|
||||||
|
"brand": "B",
|
||||||
|
"category": "serum",
|
||||||
|
"recommended_time": "both",
|
||||||
|
"leave_on": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
jobs = session.exec(select(PricingRecalcJob)).all()
|
||||||
|
assert len(jobs) == 1
|
||||||
|
assert jobs[0].status in {"pending", "running", "succeeded"}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
#
|
#
|
||||||
# The innercontext user needs passwordless sudo for systemctl only:
|
# The innercontext user needs passwordless sudo for systemctl only:
|
||||||
# /etc/sudoers.d/innercontext-deploy:
|
# /etc/sudoers.d/innercontext-deploy:
|
||||||
# innercontext ALL=(root) NOPASSWD: /usr/bin/systemctl restart innercontext, /usr/bin/systemctl restart innercontext-node, /usr/bin/systemctl is-active innercontext, /usr/bin/systemctl is-active innercontext-node
|
# innercontext ALL=(root) NOPASSWD: /usr/bin/systemctl restart innercontext, /usr/bin/systemctl restart innercontext-node, /usr/bin/systemctl restart innercontext-pricing-worker, /usr/bin/systemctl is-active innercontext, /usr/bin/systemctl is-active innercontext-node, /usr/bin/systemctl is-active innercontext-pricing-worker
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SERVER="${DEPLOY_SERVER:-innercontext}" # ssh host alias or user@host
|
SERVER="${DEPLOY_SERVER:-innercontext}" # ssh host alias or user@host
|
||||||
|
|
@ -45,8 +45,8 @@ deploy_backend() {
|
||||||
echo "==> [backend] Syncing dependencies..."
|
echo "==> [backend] Syncing dependencies..."
|
||||||
ssh "$SERVER" "cd $REMOTE/backend && uv sync --frozen --no-dev --no-editable"
|
ssh "$SERVER" "cd $REMOTE/backend && uv sync --frozen --no-dev --no-editable"
|
||||||
|
|
||||||
echo "==> [backend] Restarting service (alembic runs on start)..."
|
echo "==> [backend] Restarting services (alembic runs on API start)..."
|
||||||
ssh "$SERVER" "sudo systemctl restart innercontext && echo OK"
|
ssh "$SERVER" "sudo systemctl restart innercontext && sudo systemctl restart innercontext-pricing-worker && echo OK"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Dispatch ───────────────────────────────────────────────────────────────
|
# ── Dispatch ───────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -222,17 +222,20 @@ chown innercontext:innercontext /opt/innercontext/frontend/.env.production
|
||||||
cat > /etc/sudoers.d/innercontext-deploy << 'EOF'
|
cat > /etc/sudoers.d/innercontext-deploy << 'EOF'
|
||||||
innercontext ALL=(root) NOPASSWD: \
|
innercontext ALL=(root) NOPASSWD: \
|
||||||
/usr/bin/systemctl restart innercontext, \
|
/usr/bin/systemctl restart innercontext, \
|
||||||
/usr/bin/systemctl restart innercontext-node
|
/usr/bin/systemctl restart innercontext-node, \
|
||||||
|
/usr/bin/systemctl restart innercontext-pricing-worker
|
||||||
EOF
|
EOF
|
||||||
chmod 440 /etc/sudoers.d/innercontext-deploy
|
chmod 440 /etc/sudoers.d/innercontext-deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
### Install systemd service
|
### Install systemd services
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp /opt/innercontext/systemd/innercontext-node.service /etc/systemd/system/
|
cp /opt/innercontext/systemd/innercontext-node.service /etc/systemd/system/
|
||||||
|
cp /opt/innercontext/systemd/innercontext-pricing-worker.service /etc/systemd/system/
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable innercontext-node
|
systemctl enable innercontext-node
|
||||||
|
systemctl enable --now innercontext-pricing-worker
|
||||||
# Do NOT start yet — build/ is empty until the first deploy.sh run
|
# Do NOT start yet — build/ is empty until the first deploy.sh run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -310,6 +313,7 @@ This will:
|
||||||
4. Upload `backend/` source to the server
|
4. Upload `backend/` source to the server
|
||||||
5. Run `uv sync --frozen` on the server
|
5. Run `uv sync --frozen` on the server
|
||||||
6. Restart `innercontext` (runs alembic migrations on start)
|
6. Restart `innercontext` (runs alembic migrations on start)
|
||||||
|
7. Restart `innercontext-pricing-worker`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -347,6 +351,14 @@ journalctl -u innercontext -n 50
|
||||||
# Check .env DATABASE_URL is correct and PG LXC accepts connections
|
# Check .env DATABASE_URL is correct and PG LXC accepts connections
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Product prices stay empty / stale
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status innercontext-pricing-worker
|
||||||
|
journalctl -u innercontext-pricing-worker -n 50
|
||||||
|
# Ensure worker is running and can connect to PostgreSQL
|
||||||
|
```
|
||||||
|
|
||||||
### 502 Bad Gateway on `/`
|
### 502 Bad Gateway on `/`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import type {
|
||||||
MedicationUsage,
|
MedicationUsage,
|
||||||
PartOfDay,
|
PartOfDay,
|
||||||
Product,
|
Product,
|
||||||
|
ProductSummary,
|
||||||
ProductContext,
|
ProductContext,
|
||||||
ProductEffectProfile,
|
ProductEffectProfile,
|
||||||
ProductInventory,
|
ProductInventory,
|
||||||
|
|
@ -70,6 +71,20 @@ export function getProducts(
|
||||||
return api.get(`/products${qs ? `?${qs}` : ""}`);
|
return api.get(`/products${qs ? `?${qs}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getProductSummaries(
|
||||||
|
params: ProductListParams = {},
|
||||||
|
): Promise<ProductSummary[]> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params.category) q.set("category", params.category);
|
||||||
|
if (params.brand) q.set("brand", params.brand);
|
||||||
|
if (params.targets) params.targets.forEach((t) => q.append("targets", t));
|
||||||
|
if (params.is_medication != null)
|
||||||
|
q.set("is_medication", String(params.is_medication));
|
||||||
|
if (params.is_tool != null) q.set("is_tool", String(params.is_tool));
|
||||||
|
const qs = q.toString();
|
||||||
|
return api.get(`/products/summary${qs ? `?${qs}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
export const getProduct = (id: string): Promise<Product> =>
|
export const getProduct = (id: string): Promise<Product> =>
|
||||||
api.get(`/products/${id}`);
|
api.get(`/products/${id}`);
|
||||||
export const createProduct = (
|
export const createProduct = (
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,19 @@ export interface Product {
|
||||||
inventory: ProductInventory[];
|
inventory: ProductInventory[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProductSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
category: ProductCategory;
|
||||||
|
recommended_time: DayTime;
|
||||||
|
targets: SkinConcern[];
|
||||||
|
is_owned: boolean;
|
||||||
|
price_tier?: PriceTier;
|
||||||
|
price_per_use_pln?: number;
|
||||||
|
price_tier_source?: PriceTierSource;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Routine types ───────────────────────────────────────────────────────────
|
// ─── Routine types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface RoutineStep {
|
export interface RoutineStep {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { getProducts } from '$lib/api';
|
import { getProductSummaries } from '$lib/api';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
const products = await getProducts();
|
const products = await getProductSummaries();
|
||||||
return { products };
|
return { products };
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import type { Product } from '$lib/types';
|
import type { ProductSummary } from '$lib/types';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
@ -31,8 +31,8 @@
|
||||||
'spf', 'mask', 'exfoliant', 'hair_treatment', 'tool', 'spot_treatment', 'oil'
|
'spf', 'mask', 'exfoliant', 'hair_treatment', 'tool', 'spot_treatment', 'oil'
|
||||||
];
|
];
|
||||||
|
|
||||||
function isOwned(p: Product): boolean {
|
function isOwned(p: ProductSummary): boolean {
|
||||||
return p.inventory?.some(inv => !inv.finished_at) ?? false;
|
return p.is_owned;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSort(nextKey: SortKey): void {
|
function setSort(nextKey: SortKey): void {
|
||||||
|
|
@ -90,7 +90,7 @@
|
||||||
return sortDirection === 'asc' ? cmp : -cmp;
|
return sortDirection === 'asc' ? cmp : -cmp;
|
||||||
});
|
});
|
||||||
|
|
||||||
const map = new SvelteMap<string, Product[]>();
|
const map = new SvelteMap<string, ProductSummary[]>();
|
||||||
for (const p of items) {
|
for (const p of items) {
|
||||||
if (!map.has(p.category)) map.set(p.category, []);
|
if (!map.has(p.category)) map.set(p.category, []);
|
||||||
map.get(p.category)!.push(p);
|
map.get(p.category)!.push(p);
|
||||||
|
|
@ -121,8 +121,8 @@
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPricePerUse(product: Product): number | undefined {
|
function getPricePerUse(product: ProductSummary): number | undefined {
|
||||||
return (product as Product & { price_per_use_pln?: number }).price_per_use_pln;
|
return product.price_per_use_pln;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCategory(value: string): string {
|
function formatCategory(value: string): string {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { addRoutineStep, deleteRoutine, deleteRoutineStep, getProducts, getRoutine } from '$lib/api';
|
import { addRoutineStep, deleteRoutine, deleteRoutineStep, getProductSummaries, getRoutine } from '$lib/api';
|
||||||
import { error, fail, redirect } from '@sveltejs/kit';
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params }) => {
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
try {
|
try {
|
||||||
const [routine, products] = await Promise.all([getRoutine(params.id), getProducts()]);
|
const [routine, products] = await Promise.all([getRoutine(params.id), getProductSummaries()]);
|
||||||
return { routine, products };
|
return { routine, products };
|
||||||
} catch {
|
} catch {
|
||||||
error(404, 'Routine not found');
|
error(404, 'Routine not found');
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { addRoutineStep, createRoutine, getProducts, suggestBatch, suggestRoutine } from '$lib/api';
|
import { addRoutineStep, createRoutine, getProductSummaries, suggestBatch, suggestRoutine } from '$lib/api';
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
const products = await getProducts();
|
const products = await getProductSummaries();
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
return { products, today };
|
return { products, today };
|
||||||
};
|
};
|
||||||
|
|
|
||||||
16
systemd/innercontext-pricing-worker.service
Normal file
16
systemd/innercontext-pricing-worker.service
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
[Unit]
|
||||||
|
Description=innercontext async pricing worker
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=innercontext
|
||||||
|
Group=innercontext
|
||||||
|
WorkingDirectory=/opt/innercontext/backend
|
||||||
|
EnvironmentFile=/opt/innercontext/backend/.env
|
||||||
|
ExecStart=/opt/innercontext/backend/.venv/bin/python -m innercontext.workers.pricing
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Loading…
Add table
Add a link
Reference in a new issue