discount profiles and discounts overall implemented and working
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-21 23:46:37 +02:00
parent c713627a2c
commit 1df8d66d5d
27 changed files with 1317 additions and 89 deletions

View File

@@ -1,24 +1,31 @@
//! Admin management of business (company) accounts and their negotiated prices.
//! Admin management of business (company) accounts and their pricing.
//!
//! Phase 1: list company accounts and, per account, set/clear a manually
//! negotiated price per product ("personal agreement"). The effective price the
//! business pays is always resolved by [`crate::shared::pricing`] (lowest of the
//! public price and the negotiated price), shown here for reference.
//! 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;
use std::collections::{HashMap, HashSet};
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder};
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_product_prices, products, _entities::users},
models::{
account_discount_profiles, account_product_prices, account_product_resolutions,
discount_profiles, products, _entities::users,
},
shared::{
guard,
money::{format_price, parse_price_to_cents},
money::{format_bp, format_price, parse_price_to_cents},
pricing,
},
};
@@ -30,18 +37,32 @@ 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)?;
// Negotiated pricing only applies to company accounts.
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,
@@ -89,27 +110,58 @@ async fn show(
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 profile_name: HashMap<i32, String> =
all_profiles.iter().map(|p| (p.id, p.name.clone())).collect();
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 list = products::Entity::find()
.order_by_asc(products::Column::Name)
.all(&ctx.db)
.await?;
let priced = pricing::price_many(&ctx, &list, Some(&company)).await?;
let manual = account_product_prices::Model::map_for_user(&ctx.db, company.id).await?;
let details = pricing::detail_many(&ctx, &list, Some(&company)).await?;
let rows: Vec<serde_json::Value> = list
.iter()
.zip(priced.iter())
.map(|(product, priced)| {
.zip(details.iter())
.map(|(product, d)| {
let covering: Vec<serde_json::Value> = d
.covering_profile_ids
.iter()
.map(|pid| json!({ "id": pid, "name": profile_name.get(pid) }))
.collect();
json!({
"product_id": product.id,
"name": product.name,
"currency": product.currency,
"regular_price": format_price(product.price_cents),
"public_price": format_price(product.effective_price_cents()),
"regular_price": format_price(d.regular_cents),
"public_price": format_price(d.public_cents),
"on_public_sale": product.on_sale(),
"manual_price": manual.get(&product.id).copied().map(format_price),
"effective_price": format_price(priced.price_cents),
"is_business": priced.is_business,
"manual_price": d.manual_cents.map(format_price),
"auto_price": d.auto_cents.map(format_price),
"auto_profile_name": d.auto_profile_id.and_then(|pid| profile_name.get(&pid)),
"auto_profile_id": d.auto_profile_id,
"collision": d.collision,
"covering": covering,
"effective_price": format_price(d.price_cents),
"is_business": d.is_business,
})
})
.collect();
@@ -119,6 +171,7 @@ async fn show(
"admin/customers/show.html",
json!({
"customer": { "id": company.id, "name": company.name, "email": company.email },
"profiles": profiles_json,
"products": rows,
"error": params.get("error"),
"lang": current_lang(&jar),
@@ -137,7 +190,6 @@ async fn set_price(
let company = company_by_id(&ctx, id).await?;
let entered = form.price.trim().to_string();
// An empty value clears the negotiated price (same as the Remove action).
if entered.is_empty() {
account_product_prices::Model::clear(&ctx.db, company.id, product_id).await?;
return format::redirect(&format!("/admin/customers/{id}"));
@@ -145,7 +197,11 @@ async fn set_price(
let cents = match parse_price_to_cents(&entered) {
Ok(cents) if cents > 0 => cents,
_ => return format::redirect(&format!("/admin/customers/{id}?error=discount-must-be-positive")),
_ => {
return format::redirect(&format!(
"/admin/customers/{id}?error=discount-must-be-positive"
))
}
};
account_product_prices::Model::upsert(&ctx.db, company.id, product_id, cents).await?;
format::redirect(&format!("/admin/customers/{id}"))
@@ -163,13 +219,82 @@ async fn remove_price(
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, product_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::ProductId.eq(product_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),
..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/{product_id}", post(set_price))
.add(
"/admin/customers/{id}/prices/{product_id}/remove",
post(remove_price),
)
.add(
"/admin/customers/{id}/resolutions/{product_id}",
post(set_resolution),
)
}