0 is out of stock and nothing is available from now on
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled

This commit is contained in:
Priec
2026-06-22 16:48:28 +02:00
parent 6828854f24
commit 681c88f85d
15 changed files with 140 additions and 39 deletions

View File

@@ -213,6 +213,8 @@ variants-options = Variants / options
add-option = Add option add-option = Add option
option-label = Option label option-label = Option label
optional = optional optional = optional
stock-untracked-hint = Leave blank = available without stock tracking
available = Available
choose-option = Choose an option choose-option = Choose an option
from-price = from { $price } from-price = from { $price }
admin-discounts = Discounts admin-discounts = Discounts

View File

@@ -213,6 +213,8 @@ variants-options = Varianty / možnosti
add-option = Pridať možnosť add-option = Pridať možnosť
option-label = Označenie možnosti option-label = Označenie možnosti
optional = voliteľné optional = voliteľné
stock-untracked-hint = Nechajte prázdne = dostupné bez sledovania zásob
available = Dostupné
choose-option = Vyberte možnosť choose-option = Vyberte možnosť
from-price = od { $price } from-price = od { $price }
admin-discounts = Zľavy admin-discounts = Zľavy

View File

@@ -37,10 +37,11 @@
{# --- Variants / options editor ------------------------------------------- #} {# --- Variants / options editor ------------------------------------------- #}
{# Each product is sold as one or more variants (a free-text label such as #} {# Each product is sold as one or more variants (a free-text label such as #}
{# "10cm x 13cm" or "5ml" plus its own price/stock, optional sku & business #} {# "10cm x 13cm" or "5ml" plus its own price). Price is required. Stock is #}
{# price). Price and stock are required; the browser blocks save if a row is #} {# optional — leave it blank ("∞") to mark the option simply available (not #}
{# incomplete. Rows are managed client-side; names are indexed (variants[i][…]) #} {# inventory-tracked). SKU and business price are optional too. Rows are #}
{# and read back by the controller. #} {# managed client-side; names are indexed (variants[i][…]) and read back by #}
{# the controller. #}
{% set opt = " (" ~ t(key="optional", lang=lang | default(value='sk')) ~ ")" %} {% set opt = " (" ~ t(key="optional", lang=lang | default(value='sk')) ~ ")" %}
<script id="variants-data" type="application/json">{{ variants | json_encode() | safe }}</script> <script id="variants-data" type="application/json">{{ variants | json_encode() | safe }}</script>
<div class="space-y-3" x-data="variantEditor(JSON.parse(document.getElementById('variants-data').textContent))"> <div class="space-y-3" x-data="variantEditor(JSON.parse(document.getElementById('variants-data').textContent))">
@@ -68,8 +69,8 @@
<input :name="`variants[${i}][sku]`" x-model="row.sku" class="{{ inp }}"> <input :name="`variants[${i}][sku]`" x-model="row.sku" class="{{ inp }}">
</div> </div>
<div class="space-y-1 sm:col-span-2"> <div class="space-y-1 sm:col-span-2">
<label class="{{ sublabel }} block truncate">{{ t(key="stock", lang=lang | default(value='sk')) }}</label> <label class="{{ sublabel }} block truncate">{{ t(key="stock", lang=lang | default(value='sk')) }}{{ opt }}</label>
<input type="number" min="0" required :name="`variants[${i}][stock]`" x-model="row.stock" class="{{ inp }}"> <input type="number" min="0" :name="`variants[${i}][stock]`" x-model="row.stock" class="{{ inp }}" placeholder="∞" title="{{ t(key='stock-untracked-hint', lang=lang | default(value='sk')) }}">
</div> </div>
<div class="space-y-1 sm:col-span-2"> <div class="space-y-1 sm:col-span-2">
<label class="{{ sublabel }} block truncate">{{ t(key="price", lang=lang | default(value='sk')) }}</label> <label class="{{ sublabel }} block truncate">{{ t(key="price", lang=lang | default(value='sk')) }}</label>

View File

@@ -32,8 +32,8 @@
{% if product.has_options %} {% if product.has_options %}
{# Multiple variants: customer must pick on the product page. #} {# Multiple variants: customer must pick on the product page. #}
{{ ui::button(label=t(key="choose-option", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, extra="w-full") }} {{ ui::button(label=t(key="choose-option", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, extra="w-full") }}
{% elif product.stock > 0 %} {% elif product.in_stock %}
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}</p> <p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{% if product.tracked %}{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}{% else %}{{ t(key="available", lang=lang | default(value='sk')) }}{% endif %}</p>
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none" <form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none"
hx-on::after-request="if (event.detail.successful) toast('{{ t(key='cart-added', lang=lang | default(value='sk')) }}')"> hx-on::after-request="if (event.detail.successful) toast('{{ t(key='cart-added', lang=lang | default(value='sk')) }}')">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf" value="{{ csrf_token() }}">

View File

@@ -37,7 +37,7 @@
hx-post="/cart/update" hx-trigger="cartchange" hx-target="#cart-body" hx-swap="innerHTML"> hx-post="/cart/update" hx-trigger="cartchange" hx-target="#cart-body" hx-swap="innerHTML">
{{ ui::csrf_field() }} {{ ui::csrf_field() }}
<input type="hidden" name="variant_id" value="{{ item.id }}"> <input type="hidden" name="variant_id" value="{{ item.id }}">
<input type="number" name="quantity" min="0" max="{{ item.stock }}" value="{{ item.quantity }}" <input type="number" name="quantity" min="0" {% if item.stock %}max="{{ item.stock }}"{% endif %} value="{{ item.quantity }}"
@change=" @change="
if (parseInt($el.value || '0') <= 0 && !window.confirm('{{ t(key='cart-remove-confirm', lang=lang | default(value='sk')) }}')) { if (parseInt($el.value || '0') <= 0 && !window.confirm('{{ t(key='cart-remove-confirm', lang=lang | default(value='sk')) }}')) {
$el.value = '{{ item.quantity }}'; $el.value = '{{ item.quantity }}';

View File

@@ -98,7 +98,14 @@
</div> </div>
<button type="submit" class="{{ btn }}">{{ t(key="add-to-cart", lang=lang | default(value='sk')) }}</button> <button type="submit" class="{{ btn }}">{{ t(key="add-to-cart", lang=lang | default(value='sk')) }}</button>
</form> </form>
<p class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="in-stock", lang=lang | default(value='sk')) }}: <span x-text="current.stock"></span></p> <p class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">
<template x-if="current.tracked">
<span>{{ t(key="in-stock", lang=lang | default(value='sk')) }}: <span x-text="current.stock"></span></span>
</template>
<template x-if="!current.tracked">
<span>{{ t(key="available", lang=lang | default(value='sk')) }}</span>
</template>
</p>
</div> </div>
</template> </template>
<template x-if="!current.in_stock"> <template x-if="!current.in_stock">

View File

@@ -41,6 +41,7 @@ mod m20260621_000003_discount_profiles;
mod m20260621_000004_add_business_sale_price_to_products; mod m20260621_000004_add_business_sale_price_to_products;
mod m20260622_000001_audience_discount_profiles; mod m20260622_000001_audience_discount_profiles;
mod m20260622_000002_product_variants; mod m20260622_000002_product_variants;
mod m20260622_000003_variant_stock_nullable;
pub struct Migrator; pub struct Migrator;
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -86,6 +87,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260621_000004_add_business_sale_price_to_products::Migration), Box::new(m20260621_000004_add_business_sale_price_to_products::Migration),
Box::new(m20260622_000001_audience_discount_profiles::Migration), Box::new(m20260622_000001_audience_discount_profiles::Migration),
Box::new(m20260622_000002_product_variants::Migration), Box::new(m20260622_000002_product_variants::Migration),
Box::new(m20260622_000003_variant_stock_nullable::Migration),
// inject-above (do not remove this comment) // inject-above (do not remove this comment)
] ]
} }

View File

@@ -0,0 +1,36 @@
//! Make `product_variants.stock` nullable: a NULL stock means the variant is
//! "available" but not inventory-tracked — always purchasable, no quantity cap,
//! and never decremented on order. A numeric stock is tracked/capped as before.
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.get_connection()
.execute_unprepared(
r#"
ALTER TABLE product_variants ALTER COLUMN stock DROP DEFAULT;
ALTER TABLE product_variants ALTER COLUMN stock DROP NOT NULL;
"#,
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.get_connection()
.execute_unprepared(
r#"
UPDATE product_variants SET stock = 0 WHERE stock IS NULL;
ALTER TABLE product_variants ALTER COLUMN stock SET DEFAULT 0;
ALTER TABLE product_variants ALTER COLUMN stock SET NOT NULL;
"#,
)
.await?;
Ok(())
}
}

View File

@@ -102,7 +102,8 @@ struct VariantInput {
id: Option<i32>, id: Option<i32>,
label: String, label: String,
sku: Option<String>, sku: Option<String>,
stock: i32, /// `None` = available but not inventory-tracked.
stock: Option<i32>,
price_cents: i64, price_cents: i64,
business_sale_cents: Option<i64>, business_sale_cents: Option<i64>,
position: i32, position: i32,
@@ -156,11 +157,17 @@ fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
} }
let sku = form.text(&format!("variants[{i}][sku]")); let sku = form.text(&format!("variants[{i}][sku]"));
let stock = form // Stock is optional: blank means "available, not tracked". A value must
.text(&format!("variants[{i}][stock]")) // be a non-negative integer.
.and_then(|s| s.parse::<i32>().ok()) let stock = match form.text(&format!("variants[{i}][stock]")) {
.filter(|n| *n >= 0) None => None,
.ok_or_else(|| Error::BadRequest("each option needs a stock quantity".to_string()))?; Some(raw) => Some(
raw.parse::<i32>()
.ok()
.filter(|n| *n >= 0)
.ok_or_else(|| Error::BadRequest("stock must be 0 or more".to_string()))?,
),
};
let business_sale_cents = parse_optional_sale(form, i, "business_sale", price_cents)?; let business_sale_cents = parse_optional_sale(form, i, "business_sale", price_cents)?;
let id = form let id = form
.text(&format!("variants[{i}][id]")) .text(&format!("variants[{i}][id]"))
@@ -315,12 +322,22 @@ async fn index(
let category_name = product let category_name = product
.category_id .category_id
.and_then(|id| category_name.get(&id).cloned()); .and_then(|id| category_name.get(&id).cloned());
let total_stock: i32 = variants.iter().map(|v| v.stock).sum(); // Stock column: total across tracked variants, or "∞" when any option is
// untracked (always available).
let stock_display = if variants.iter().any(|v| !v.tracked()) {
"".to_string()
} else {
variants
.iter()
.filter_map(|v| v.stock)
.sum::<i32>()
.to_string()
};
rows.push(product_row( rows.push(product_row(
product, product,
priced, priced,
variants.len(), variants.len(),
total_stock, stock_display,
image, image,
category_name, category_name,
)); ));
@@ -349,7 +366,7 @@ fn product_row(
product: &products::Model, product: &products::Model,
effective: &pricing::PricedProduct, effective: &pricing::PricedProduct,
variant_count: usize, variant_count: usize,
total_stock: i32, stock_display: String,
image: Option<String>, image: Option<String>,
category_name: Option<String>, category_name: Option<String>,
) -> serde_json::Value { ) -> serde_json::Value {
@@ -358,7 +375,7 @@ fn product_row(
"name": product.name, "name": product.name,
"slug": product.slug, "slug": product.slug,
"currency": product.currency, "currency": product.currency,
"stock": total_stock, "stock": stock_display,
"variant_count": variant_count, "variant_count": variant_count,
"has_options": variant_count > 1, "has_options": variant_count > 1,
"published": product.published, "published": product.published,

View File

@@ -97,9 +97,9 @@ async fn add(
let mut items = parse_cart(&jar); let mut items = parse_cart(&jar);
let add_qty = form.quantity.unwrap_or(1).max(1); let add_qty = form.quantity.unwrap_or(1).max(1);
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) { if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) {
entry.1 = (entry.1 + add_qty).min(variant.stock); entry.1 = variant.cap(entry.1 + add_qty);
} else { } else {
items.push((variant.id, add_qty.min(variant.stock))); items.push((variant.id, variant.cap(add_qty)));
} }
items.retain(|(_, qty)| *qty > 0); items.retain(|(_, qty)| *qty > 0);
@@ -128,13 +128,14 @@ async fn update(
headers: HeaderMap, headers: HeaderMap,
Form(form): Form<UpdateForm>, Form(form): Form<UpdateForm>,
) -> Result<Response> { ) -> Result<Response> {
let stock = published_variant(&ctx, form.variant_id) // Clamp the requested quantity to what's available (no cap for untracked
.await? // variants); a removed variant clamps to 0 and drops out below.
.map(|(v, _)| v.stock) let clamped = match published_variant(&ctx, form.variant_id).await? {
.unwrap_or(0); Some((variant, _)) => variant.cap(form.quantity),
None => 0,
};
let mut items = parse_cart(&jar); let mut items = parse_cart(&jar);
let clamped = form.quantity.clamp(0, stock);
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.variant_id) { if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.variant_id) {
entry.1 = clamped; entry.1 = clamped;
} }
@@ -208,7 +209,7 @@ pub(crate) async fn resolve_cart(
let Some((variant, product)) = published_variant(ctx, id).await? else { let Some((variant, product)) = published_variant(ctx, id).await? else {
continue; continue;
}; };
let qty = qty.clamp(0, variant.stock); let qty = variant.cap(qty);
if qty == 0 { if qty == 0 {
continue; continue;
} }

View File

@@ -14,7 +14,7 @@ pub struct Model {
pub label: String, pub label: String,
pub position: i32, pub position: i32,
pub sku: Option<String>, pub sku: Option<String>,
pub stock: i32, pub stock: Option<i32>,
pub price_cents: i64, pub price_cents: i64,
pub sale_price_cents: Option<i64>, pub sale_price_cents: Option<i64>,
pub business_sale_price_cents: Option<i64>, pub business_sale_price_cents: Option<i64>,

View File

@@ -65,11 +65,15 @@ pub async fn place(
.one(&txn) .one(&txn)
.await? .await?
.ok_or_else(|| Error::BadRequest("a product is no longer available".to_string()))?; .ok_or_else(|| Error::BadRequest("a product is no longer available".to_string()))?;
if variant.stock < *qty { // Tracked variants can't oversell; untracked ones (stock = None) are
return Err(Error::BadRequest(format!( // always available and never decremented.
"not enough stock for {}", if let Some(on_hand) = variant.stock {
product.name if on_hand < *qty {
))); return Err(Error::BadRequest(format!(
"not enough stock for {}",
product.name
)));
}
} }
currency = product.currency.clone(); currency = product.currency.clone();
// Snapshot the price the buyer actually pays — public sale or, for a // Snapshot the price the buyer actually pays — public sale or, for a
@@ -78,9 +82,11 @@ pub async fn place(
let unit_price_cents = pricing::price_variant(ctx, &variant, user).await?.price_cents; let unit_price_cents = pricing::price_variant(ctx, &variant, user).await?.price_cents;
subtotal += unit_price_cents * i64::from(*qty); subtotal += unit_price_cents * i64::from(*qty);
let mut active = variant.clone().into_active_model(); if let Some(on_hand) = variant.stock {
active.stock = Set(variant.stock - *qty); let mut active = variant.clone().into_active_model();
active.update(&txn).await?; active.stock = Set(Some(on_hand - *qty));
active.update(&txn).await?;
}
snapshots.push((product.id, variant.id, product.name, variant.label, unit_price_cents, *qty)); snapshots.push((product.id, variant.id, product.name, variant.label, unit_price_cents, *qty));
} }

View File

@@ -45,6 +45,30 @@ impl Model {
pub fn business_on_sale(&self) -> bool { pub fn business_on_sale(&self) -> bool {
matches!(self.business_sale_price_cents, Some(sale) if sale < self.price_cents) matches!(self.business_sale_price_cents, Some(sale) if sale < self.price_cents)
} }
/// Whether the variant's inventory is tracked. A `None` stock means
/// "available, not tracked" (always purchasable, unlimited).
#[must_use]
pub fn tracked(&self) -> bool {
self.stock.is_some()
}
/// Whether the variant can currently be bought: untracked variants are always
/// available; tracked ones need a positive quantity on hand.
#[must_use]
pub fn in_stock(&self) -> bool {
self.stock.map_or(true, |s| s > 0)
}
/// Clamp a desired quantity to what's available: capped at the tracked stock,
/// or left as-is (only floored at 0) when untracked.
#[must_use]
pub fn cap(&self, qty: i32) -> i32 {
match self.stock {
Some(s) => qty.clamp(0, s),
None => qty.max(0),
}
}
} }
// implement your write-oriented logic here // implement your write-oriented logic here

View File

@@ -177,7 +177,7 @@ pub async fn seed_catalog(ctx: &AppContext) -> Result<()> {
label: Set(String::new()), label: Set(String::new()),
position: Set(0), position: Set(0),
sku: Set(item.sku.map(|s| s.to_string())), sku: Set(item.sku.map(|s| s.to_string())),
stock: Set(item.stock), stock: Set(Some(item.stock)),
price_cents: Set(item.price_cents), price_cents: Set(item.price_cents),
..Default::default() ..Default::default()
} }

View File

@@ -34,6 +34,8 @@ pub fn product_card(
"currency": product.currency, "currency": product.currency,
"sku": representative.sku, "sku": representative.sku,
"stock": representative.stock, "stock": representative.stock,
"tracked": representative.tracked(),
"in_stock": representative.in_stock(),
"variant_count": variant_count, "variant_count": variant_count,
"has_options": variant_count > 1, "has_options": variant_count > 1,
"published": product.published, "published": product.published,
@@ -49,7 +51,8 @@ pub fn variant_option(variant: &product_variants::Model, priced: &PricedProduct)
"label": variant.label, "label": variant.label,
"sku": variant.sku, "sku": variant.sku,
"stock": variant.stock, "stock": variant.stock,
"in_stock": variant.stock > 0, "tracked": variant.tracked(),
"in_stock": variant.in_stock(),
"price": format_price(priced.price_cents), "price": format_price(priced.price_cents),
"on_sale": priced.is_reduced(), "on_sale": priced.is_reduced(),
"regular_price": format_price(priced.regular_cents), "regular_price": format_price(priced.regular_cents),