now products have different options, like different parameters
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 15:44:02 +02:00
parent 29854a972b
commit 3f798432a0
52 changed files with 1281 additions and 628 deletions

View File

@@ -21,7 +21,7 @@ use crate::{
controllers::i18n::current_lang,
models::{
account_discount_profiles, account_product_prices, account_product_resolutions,
categories, discount_profiles, products, _entities::users,
categories, discount_profiles, product_variants, products, _entities::users,
},
shared::{
guard,
@@ -142,16 +142,9 @@ async fn show(
.order_by_asc(products::Column::Name)
.all(&ctx.db)
.await?;
// Two prices per product:
// - the generic business price a freshly-registered company sees (business
// baseline + business-audience profiles, no per-company deals), and
// - this company's effective price (its negotiated price + assigned profiles).
// The effective price is highlighted only when it differs from the generic one.
let business = pricing::audience_price_many(&ctx, &list, BUSINESS_AUDIENCE).await?;
let details = pricing::detail_many(&ctx, &list, Some(&company)).await?;
// Category sidebar tree (counts over the full, unfiltered list) plus the
// active `?category=` filter applied to the rows.
// Category sidebar tree (counts over the full, unfiltered product list) plus
// the active `?category=` filter applied to the rows.
let category_ids: Vec<Option<i32>> = list.iter().map(|p| p.category_id).collect();
let category_groups = view::admin_category_groups(&all_categories, &category_ids);
let selected_category = params
@@ -161,15 +154,43 @@ async fn show(
.to_string();
let filter = view::category_filter_ids(&all_categories, &selected_category);
let rows: Vec<serde_json::Value> = list
// Pricing is per variant. Flatten the (filtered) products into their variants
// in product-name then variant-position order, carrying each variant's
// product for the row's display name.
let product_ids: Vec<i32> = list.iter().map(|p| p.id).collect();
let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &product_ids).await?;
let mut variant_rows: Vec<(&products::Model, product_variants::Model)> = Vec::new();
for product in &list {
if !view::category_filter_keep(&filter, product.category_id) {
continue;
}
if let Some(variants) = grouped.get(&product.id) {
for variant in variants {
variant_rows.push((product, variant.clone()));
}
}
}
// Two prices per variant:
// - the generic business price a freshly-registered company sees (business
// baseline + business-audience profiles, no per-company deals), and
// - this company's effective price (its negotiated price + assigned profiles).
// The effective price is highlighted only when it differs from the generic one.
let variants_only: Vec<product_variants::Model> =
variant_rows.iter().map(|(_, v)| v.clone()).collect();
let business = pricing::audience_price_variants(&ctx, &variants_only, BUSINESS_AUDIENCE).await?;
let details = pricing::detail_variants(&ctx, &variants_only, Some(&company)).await?;
let rows: Vec<serde_json::Value> = variant_rows
.iter()
.zip(business.iter())
.zip(details.iter())
.filter(|((product, _), _)| view::category_filter_keep(&filter, product.category_id))
.map(|((product, b), d)| {
.map(|(((product, variant), b), d)| {
json!({
"product_id": product.id,
"variant_id": variant.id,
"name": product.name,
"variant_label": variant.label,
"currency": product.currency,
"regular_price": format_price(d.regular_cents),
"business_price": format_price(b.price_cents),
@@ -207,22 +228,27 @@ async fn price_edit(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path((id, product_id)): Path<(i32, i32)>,
Path((id, variant_id)): Path<(i32, i32)>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let company = company_by_id(&ctx, id).await?;
let product = products::Entity::find_by_id(product_id)
let variant = product_variants::Entity::find_by_id(variant_id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let product = products::Entity::find_by_id(variant.product_id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let business =
pricing::audience_price_many(&ctx, std::slice::from_ref(&product), BUSINESS_AUDIENCE)
pricing::audience_price_variants(&ctx, std::slice::from_ref(&variant), BUSINESS_AUDIENCE)
.await?;
let business_cents = business[0].price_cents;
let detail = pricing::detail_many(&ctx, std::slice::from_ref(&product), Some(&company)).await?;
let detail =
pricing::detail_variants(&ctx, std::slice::from_ref(&variant), Some(&company)).await?;
let d = &detail[0];
// Names for the covering profiles, used by the collision resolution selector.
@@ -248,7 +274,9 @@ async fn price_edit(
"customer": { "id": company.id, "name": company.name },
"product": {
"id": product.id,
"variant_id": variant.id,
"name": product.name,
"variant_label": variant.label,
"currency": product.currency,
"regular_price": format_price(d.regular_cents),
"regular_cents": d.regular_cents,
@@ -271,7 +299,7 @@ async fn price_edit(
#[debug_handler]
async fn set_price(
auth: auth::JWT,
Path((id, product_id)): Path<(i32, i32)>,
Path((id, variant_id)): Path<(i32, i32)>,
State(ctx): State<AppContext>,
Form(form): Form<PriceForm>,
) -> Result<Response> {
@@ -280,7 +308,7 @@ async fn set_price(
let entered = form.price.trim().to_string();
if entered.is_empty() {
account_product_prices::Model::clear(&ctx.db, company.id, product_id).await?;
account_product_prices::Model::clear(&ctx.db, company.id, variant_id).await?;
return format::redirect(&format!("/admin/customers/{id}"));
}
@@ -288,23 +316,23 @@ async fn set_price(
Ok(cents) if cents > 0 => cents,
_ => {
return format::redirect(&format!(
"/admin/customers/{id}/prices/{product_id}/edit?error=discount-must-be-positive"
"/admin/customers/{id}/prices/{variant_id}/edit?error=discount-must-be-positive"
))
}
};
account_product_prices::Model::upsert(&ctx.db, company.id, product_id, cents).await?;
account_product_prices::Model::upsert(&ctx.db, company.id, variant_id, cents).await?;
format::redirect(&format!("/admin/customers/{id}"))
}
#[debug_handler]
async fn remove_price(
auth: auth::JWT,
Path((id, product_id)): Path<(i32, i32)>,
Path((id, variant_id)): Path<(i32, i32)>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let company = company_by_id(&ctx, id).await?;
account_product_prices::Model::clear(&ctx.db, company.id, product_id).await?;
account_product_prices::Model::clear(&ctx.db, company.id, variant_id).await?;
format::redirect(&format!("/admin/customers/{id}"))
}
@@ -347,7 +375,7 @@ async fn sync_profiles(
#[debug_handler]
async fn set_resolution(
auth: auth::JWT,
Path((id, product_id)): Path<(i32, i32)>,
Path((id, variant_id)): Path<(i32, i32)>,
State(ctx): State<AppContext>,
Form(form): Form<ResolutionForm>,
) -> Result<Response> {
@@ -356,14 +384,14 @@ async fn set_resolution(
let existing = account_product_resolutions::Entity::find()
.filter(account_product_resolutions::Column::UserId.eq(company.id))
.filter(account_product_resolutions::Column::ProductId.eq(product_id))
.filter(account_product_resolutions::Column::VariantId.eq(variant_id))
.one(&ctx.db)
.await?;
let mut active = match existing {
Some(row) => row.into_active_model(),
None => account_product_resolutions::ActiveModel {
user_id: Set(company.id),
product_id: Set(product_id),
variant_id: Set(variant_id),
..Default::default()
},
};
@@ -378,16 +406,16 @@ pub fn routes() -> Routes {
.add("/admin/customers/{id}", get(show))
.add("/admin/customers/{id}/profiles", post(sync_profiles))
.add(
"/admin/customers/{id}/prices/{product_id}/edit",
"/admin/customers/{id}/prices/{variant_id}/edit",
get(price_edit),
)
.add("/admin/customers/{id}/prices/{product_id}", post(set_price))
.add("/admin/customers/{id}/prices/{variant_id}", post(set_price))
.add(
"/admin/customers/{id}/prices/{product_id}/remove",
"/admin/customers/{id}/prices/{variant_id}/remove",
post(remove_price),
)
.add(
"/admin/customers/{id}/resolutions/{product_id}",
"/admin/customers/{id}/resolutions/{variant_id}",
post(set_resolution),
)
}