430 lines
15 KiB
Rust
430 lines
15 KiB
Rust
//! Admin management of business (company) accounts and their pricing.
|
|
//!
|
|
//! Per company the admin can: assign reusable discount profiles (the automated
|
|
//! layer), resolve per-product collisions when two assigned profiles cover the
|
|
//! same product, and set a manually negotiated price per product. The effective
|
|
//! price the business pays is always resolved by [`crate::shared::pricing`]
|
|
//! (lowest of public / automated / negotiated), shown here for reference.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
use axum_extra::extract::cookie::CookieJar;
|
|
use loco_rs::prelude::*;
|
|
use sea_orm::{
|
|
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
|
|
QueryOrder, Set, TransactionTrait,
|
|
};
|
|
use serde::Deserialize;
|
|
use serde_json::json;
|
|
|
|
use crate::{
|
|
controllers::i18n::current_lang,
|
|
models::{
|
|
account_discount_profiles, account_product_prices, account_product_resolutions,
|
|
categories, discount_profiles, product_variants, products, _entities::users,
|
|
},
|
|
shared::{
|
|
guard,
|
|
money::{format_bp, format_price, parse_price_to_cents},
|
|
pricing,
|
|
},
|
|
views::shop as view,
|
|
};
|
|
|
|
const COMPANY: &str = "company";
|
|
const BUSINESS_AUDIENCE: &str = "business";
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct PriceForm {
|
|
price: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ResolutionForm {
|
|
profile_id: i32,
|
|
}
|
|
|
|
async fn company_by_id(ctx: &AppContext, id: i32) -> Result<users::Model> {
|
|
let user = users::Entity::find_by_id(id)
|
|
.one(&ctx.db)
|
|
.await?
|
|
.ok_or_else(|| Error::NotFound)?;
|
|
if user.account_type != COMPANY {
|
|
return Err(Error::NotFound);
|
|
}
|
|
Ok(user)
|
|
}
|
|
|
|
async fn assigned_profile_ids(ctx: &AppContext, user_id: i32) -> Result<HashSet<i32>> {
|
|
Ok(account_discount_profiles::Entity::find()
|
|
.filter(account_discount_profiles::Column::UserId.eq(user_id))
|
|
.all(&ctx.db)
|
|
.await?
|
|
.into_iter()
|
|
.map(|a| a.discount_profile_id)
|
|
.collect())
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn index(
|
|
auth: auth::JWT,
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
guard::current_admin(auth, &ctx).await?;
|
|
let companies = users::Entity::find()
|
|
.filter(users::Column::AccountType.eq(COMPANY))
|
|
.order_by_asc(users::Column::Name)
|
|
.all(&ctx.db)
|
|
.await?;
|
|
|
|
let mut rows = Vec::with_capacity(companies.len());
|
|
for company in &companies {
|
|
let negotiated = account_product_prices::Entity::find()
|
|
.filter(account_product_prices::Column::UserId.eq(company.id))
|
|
.count(&ctx.db)
|
|
.await?;
|
|
rows.push(json!({
|
|
"id": company.id,
|
|
"name": company.name,
|
|
"email": company.email,
|
|
"negotiated_count": negotiated,
|
|
}));
|
|
}
|
|
|
|
format::view(
|
|
&v,
|
|
"admin/customers/index.html",
|
|
json!({ "customers": rows, "lang": current_lang(&jar) }),
|
|
)
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn show(
|
|
auth: auth::JWT,
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
Path(id): Path<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?;
|
|
|
|
// All profiles (for the assignment section + name lookup) and which are
|
|
// assigned to this company.
|
|
let all_profiles = discount_profiles::Entity::find()
|
|
.order_by_asc(discount_profiles::Column::Name)
|
|
.all(&ctx.db)
|
|
.await?;
|
|
let assigned = assigned_profile_ids(&ctx, company.id).await?;
|
|
let profiles_json: Vec<serde_json::Value> = all_profiles
|
|
.iter()
|
|
.map(|p| {
|
|
json!({
|
|
"id": p.id,
|
|
"name": p.name,
|
|
"percent": format_bp(p.percent_bp),
|
|
"scope_type": p.scope_type,
|
|
"assigned": assigned.contains(&p.id),
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
let all_categories = categories::Entity::find()
|
|
.order_by_asc(categories::Column::Position)
|
|
.order_by_asc(categories::Column::Name)
|
|
.all(&ctx.db)
|
|
.await?;
|
|
|
|
// Optional text search (drafts included), otherwise the whole catalog by
|
|
// name. Reuses the storefront's hybrid full-text + fuzzy product search.
|
|
let query = params.get("q").map(String::as_str).unwrap_or("").trim().to_string();
|
|
let list = if query.is_empty() {
|
|
products::Entity::find()
|
|
.order_by_asc(products::Column::Name)
|
|
.all(&ctx.db)
|
|
.await?
|
|
} else {
|
|
products::Entity::search(&ctx.db, &query, 1000, false).await?
|
|
};
|
|
|
|
// 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
|
|
.get("category")
|
|
.map(String::as_str)
|
|
.unwrap_or("all")
|
|
.to_string();
|
|
let filter = view::category_filter_ids(&all_categories, &selected_category);
|
|
|
|
// 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())
|
|
.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),
|
|
"business_reduced": b.price_cents < d.regular_cents,
|
|
"has_negotiated": d.manual_cents.is_some(),
|
|
"collision": d.collision,
|
|
"effective_price": format_price(d.price_cents),
|
|
"effective_differs": d.price_cents != b.price_cents,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
format::view(
|
|
&v,
|
|
"admin/customers/show.html",
|
|
json!({
|
|
"customer": { "id": company.id, "name": company.name, "email": company.email },
|
|
"profiles": profiles_json,
|
|
"products": rows,
|
|
"category_groups": category_groups,
|
|
"selected_category": selected_category,
|
|
"query": query,
|
|
"total_count": list.len(),
|
|
"uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(),
|
|
"error": params.get("error"),
|
|
"lang": current_lang(&jar),
|
|
}),
|
|
)
|
|
}
|
|
|
|
/// Dedicated per-product page for the negotiated price (and, when two assigned
|
|
/// profiles collide, the resolution selector). Mirrors the catalog "Set discount"
|
|
/// page but for a single company.
|
|
#[debug_handler]
|
|
async fn price_edit(
|
|
auth: auth::JWT,
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
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 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_variants(&ctx, std::slice::from_ref(&variant), BUSINESS_AUDIENCE)
|
|
.await?;
|
|
let business_cents = business[0].price_cents;
|
|
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.
|
|
let covering: Vec<serde_json::Value> = if d.covering_profile_ids.is_empty() {
|
|
Vec::new()
|
|
} else {
|
|
let profiles = discount_profiles::Entity::find()
|
|
.filter(discount_profiles::Column::Id.is_in(d.covering_profile_ids.clone()))
|
|
.all(&ctx.db)
|
|
.await?;
|
|
let name: HashMap<i32, String> =
|
|
profiles.iter().map(|p| (p.id, p.name.clone())).collect();
|
|
d.covering_profile_ids
|
|
.iter()
|
|
.map(|pid| json!({ "id": pid, "name": name.get(pid) }))
|
|
.collect()
|
|
};
|
|
|
|
format::view(
|
|
&v,
|
|
"admin/customers/price_form.html",
|
|
json!({
|
|
"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,
|
|
"business_price": format_price(business_cents),
|
|
"business_reduced": business_cents < d.regular_cents,
|
|
"effective_price": format_price(d.price_cents),
|
|
"effective_differs": d.price_cents != business_cents,
|
|
},
|
|
"negotiated": d.manual_cents.map(format_price).unwrap_or_default(),
|
|
"has_negotiated": d.manual_cents.is_some(),
|
|
"collision": d.collision,
|
|
"covering": covering,
|
|
"auto_profile_id": d.auto_profile_id,
|
|
"error": params.get("error"),
|
|
"lang": current_lang(&jar),
|
|
}),
|
|
)
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn set_price(
|
|
auth: auth::JWT,
|
|
Path((id, variant_id)): Path<(i32, i32)>,
|
|
State(ctx): State<AppContext>,
|
|
Form(form): Form<PriceForm>,
|
|
) -> Result<Response> {
|
|
guard::current_admin(auth, &ctx).await?;
|
|
let company = company_by_id(&ctx, id).await?;
|
|
|
|
let entered = form.price.trim().to_string();
|
|
if entered.is_empty() {
|
|
account_product_prices::Model::clear(&ctx.db, company.id, variant_id).await?;
|
|
return format::redirect(&format!("/admin/customers/{id}"));
|
|
}
|
|
|
|
let cents = match parse_price_to_cents(&entered) {
|
|
Ok(cents) if cents > 0 => cents,
|
|
_ => {
|
|
return format::redirect(&format!(
|
|
"/admin/customers/{id}/prices/{variant_id}/edit?error=discount-must-be-positive"
|
|
))
|
|
}
|
|
};
|
|
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, 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, variant_id).await?;
|
|
format::redirect(&format!("/admin/customers/{id}"))
|
|
}
|
|
|
|
/// Replace the company's assigned profiles with the submitted set of checkboxes
|
|
/// (`profile_ids`, a repeated field axum `Form` can't collect, parsed directly).
|
|
#[debug_handler]
|
|
async fn sync_profiles(
|
|
auth: auth::JWT,
|
|
Path(id): Path<i32>,
|
|
State(ctx): State<AppContext>,
|
|
body: String,
|
|
) -> Result<Response> {
|
|
guard::current_admin(auth, &ctx).await?;
|
|
let company = company_by_id(&ctx, id).await?;
|
|
|
|
let profile_ids: Vec<i32> = form_urlencoded::parse(body.as_bytes())
|
|
.filter(|(k, _)| k == "profile_ids")
|
|
.filter_map(|(_, v)| v.parse::<i32>().ok())
|
|
.collect();
|
|
|
|
let txn = ctx.db.begin().await?;
|
|
account_discount_profiles::Entity::delete_many()
|
|
.filter(account_discount_profiles::Column::UserId.eq(company.id))
|
|
.exec(&txn)
|
|
.await?;
|
|
for profile_id in profile_ids {
|
|
account_discount_profiles::ActiveModel {
|
|
user_id: Set(company.id),
|
|
discount_profile_id: Set(profile_id),
|
|
..Default::default()
|
|
}
|
|
.insert(&txn)
|
|
.await?;
|
|
}
|
|
txn.commit().await?;
|
|
format::redirect(&format!("/admin/customers/{id}"))
|
|
}
|
|
|
|
/// Record the admin's chosen winning profile for a colliding product.
|
|
#[debug_handler]
|
|
async fn set_resolution(
|
|
auth: auth::JWT,
|
|
Path((id, variant_id)): Path<(i32, i32)>,
|
|
State(ctx): State<AppContext>,
|
|
Form(form): Form<ResolutionForm>,
|
|
) -> Result<Response> {
|
|
guard::current_admin(auth, &ctx).await?;
|
|
let company = company_by_id(&ctx, id).await?;
|
|
|
|
let existing = account_product_resolutions::Entity::find()
|
|
.filter(account_product_resolutions::Column::UserId.eq(company.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),
|
|
variant_id: Set(variant_id),
|
|
..Default::default()
|
|
},
|
|
};
|
|
active.discount_profile_id = Set(form.profile_id);
|
|
active.save(&ctx.db).await?;
|
|
format::redirect(&format!("/admin/customers/{id}"))
|
|
}
|
|
|
|
pub fn routes() -> Routes {
|
|
Routes::new()
|
|
.add("/admin/customers", get(index))
|
|
.add("/admin/customers/{id}", get(show))
|
|
.add("/admin/customers/{id}/profiles", post(sync_profiles))
|
|
.add(
|
|
"/admin/customers/{id}/prices/{variant_id}/edit",
|
|
get(price_edit),
|
|
)
|
|
.add("/admin/customers/{id}/prices/{variant_id}", post(set_price))
|
|
.add(
|
|
"/admin/customers/{id}/prices/{variant_id}/remove",
|
|
post(remove_price),
|
|
)
|
|
.add(
|
|
"/admin/customers/{id}/resolutions/{variant_id}",
|
|
post(set_resolution),
|
|
)
|
|
}
|