0 is out of stock and nothing is available from now on
This commit is contained in:
@@ -102,7 +102,8 @@ struct VariantInput {
|
||||
id: Option<i32>,
|
||||
label: String,
|
||||
sku: Option<String>,
|
||||
stock: i32,
|
||||
/// `None` = available but not inventory-tracked.
|
||||
stock: Option<i32>,
|
||||
price_cents: i64,
|
||||
business_sale_cents: Option<i64>,
|
||||
position: i32,
|
||||
@@ -156,11 +157,17 @@ fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
|
||||
}
|
||||
|
||||
let sku = form.text(&format!("variants[{i}][sku]"));
|
||||
let stock = form
|
||||
.text(&format!("variants[{i}][stock]"))
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.filter(|n| *n >= 0)
|
||||
.ok_or_else(|| Error::BadRequest("each option needs a stock quantity".to_string()))?;
|
||||
// Stock is optional: blank means "available, not tracked". A value must
|
||||
// be a non-negative integer.
|
||||
let stock = match form.text(&format!("variants[{i}][stock]")) {
|
||||
None => None,
|
||||
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 id = form
|
||||
.text(&format!("variants[{i}][id]"))
|
||||
@@ -315,12 +322,22 @@ async fn index(
|
||||
let category_name = product
|
||||
.category_id
|
||||
.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(
|
||||
product,
|
||||
priced,
|
||||
variants.len(),
|
||||
total_stock,
|
||||
stock_display,
|
||||
image,
|
||||
category_name,
|
||||
));
|
||||
@@ -349,7 +366,7 @@ fn product_row(
|
||||
product: &products::Model,
|
||||
effective: &pricing::PricedProduct,
|
||||
variant_count: usize,
|
||||
total_stock: i32,
|
||||
stock_display: String,
|
||||
image: Option<String>,
|
||||
category_name: Option<String>,
|
||||
) -> serde_json::Value {
|
||||
@@ -358,7 +375,7 @@ fn product_row(
|
||||
"name": product.name,
|
||||
"slug": product.slug,
|
||||
"currency": product.currency,
|
||||
"stock": total_stock,
|
||||
"stock": stock_display,
|
||||
"variant_count": variant_count,
|
||||
"has_options": variant_count > 1,
|
||||
"published": product.published,
|
||||
|
||||
@@ -97,9 +97,9 @@ async fn add(
|
||||
let mut items = parse_cart(&jar);
|
||||
let add_qty = form.quantity.unwrap_or(1).max(1);
|
||||
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 {
|
||||
items.push((variant.id, add_qty.min(variant.stock)));
|
||||
items.push((variant.id, variant.cap(add_qty)));
|
||||
}
|
||||
items.retain(|(_, qty)| *qty > 0);
|
||||
|
||||
@@ -128,13 +128,14 @@ async fn update(
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<UpdateForm>,
|
||||
) -> Result<Response> {
|
||||
let stock = published_variant(&ctx, form.variant_id)
|
||||
.await?
|
||||
.map(|(v, _)| v.stock)
|
||||
.unwrap_or(0);
|
||||
// Clamp the requested quantity to what's available (no cap for untracked
|
||||
// variants); a removed variant clamps to 0 and drops out below.
|
||||
let clamped = match published_variant(&ctx, form.variant_id).await? {
|
||||
Some((variant, _)) => variant.cap(form.quantity),
|
||||
None => 0,
|
||||
};
|
||||
|
||||
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) {
|
||||
entry.1 = clamped;
|
||||
}
|
||||
@@ -208,7 +209,7 @@ pub(crate) async fn resolve_cart(
|
||||
let Some((variant, product)) = published_variant(ctx, id).await? else {
|
||||
continue;
|
||||
};
|
||||
let qty = qty.clamp(0, variant.stock);
|
||||
let qty = variant.cap(qty);
|
||||
if qty == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ pub struct Model {
|
||||
pub label: String,
|
||||
pub position: i32,
|
||||
pub sku: Option<String>,
|
||||
pub stock: i32,
|
||||
pub stock: Option<i32>,
|
||||
pub price_cents: i64,
|
||||
pub sale_price_cents: Option<i64>,
|
||||
pub business_sale_price_cents: Option<i64>,
|
||||
|
||||
@@ -65,11 +65,15 @@ pub async fn place(
|
||||
.one(&txn)
|
||||
.await?
|
||||
.ok_or_else(|| Error::BadRequest("a product is no longer available".to_string()))?;
|
||||
if variant.stock < *qty {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"not enough stock for {}",
|
||||
product.name
|
||||
)));
|
||||
// Tracked variants can't oversell; untracked ones (stock = None) are
|
||||
// always available and never decremented.
|
||||
if let Some(on_hand) = variant.stock {
|
||||
if on_hand < *qty {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"not enough stock for {}",
|
||||
product.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
currency = product.currency.clone();
|
||||
// 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;
|
||||
subtotal += unit_price_cents * i64::from(*qty);
|
||||
|
||||
let mut active = variant.clone().into_active_model();
|
||||
active.stock = Set(variant.stock - *qty);
|
||||
active.update(&txn).await?;
|
||||
if let Some(on_hand) = variant.stock {
|
||||
let mut active = variant.clone().into_active_model();
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -45,6 +45,30 @@ impl Model {
|
||||
pub fn business_on_sale(&self) -> bool {
|
||||
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
|
||||
|
||||
@@ -177,7 +177,7 @@ pub async fn seed_catalog(ctx: &AppContext) -> Result<()> {
|
||||
label: Set(String::new()),
|
||||
position: Set(0),
|
||||
sku: Set(item.sku.map(|s| s.to_string())),
|
||||
stock: Set(item.stock),
|
||||
stock: Set(Some(item.stock)),
|
||||
price_cents: Set(item.price_cents),
|
||||
..Default::default()
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ pub fn product_card(
|
||||
"currency": product.currency,
|
||||
"sku": representative.sku,
|
||||
"stock": representative.stock,
|
||||
"tracked": representative.tracked(),
|
||||
"in_stock": representative.in_stock(),
|
||||
"variant_count": variant_count,
|
||||
"has_options": variant_count > 1,
|
||||
"published": product.published,
|
||||
@@ -49,7 +51,8 @@ pub fn variant_option(variant: &product_variants::Model, priced: &PricedProduct)
|
||||
"label": variant.label,
|
||||
"sku": variant.sku,
|
||||
"stock": variant.stock,
|
||||
"in_stock": variant.stock > 0,
|
||||
"tracked": variant.tracked(),
|
||||
"in_stock": variant.in_stock(),
|
||||
"price": format_price(priced.price_cents),
|
||||
"on_sale": priced.is_reduced(),
|
||||
"regular_price": format_price(priced.regular_cents),
|
||||
|
||||
Reference in New Issue
Block a user