feat(freight): shop freight templates, server-side checkout fees, company dictionary (add-freight-templates)
This commit is contained in:
@@ -97,6 +97,8 @@ pub struct Product {
|
||||
pub description: serde_json::Value,
|
||||
pub images: serde_json::Value,
|
||||
pub status: ProductStatus,
|
||||
/// Optional freight template of the same shop; wins over the shop default.
|
||||
pub freight_template_id: Option<Uuid>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -252,6 +254,8 @@ pub struct Shipment {
|
||||
pub order_id: Uuid,
|
||||
pub carrier: String,
|
||||
pub tracking_no: String,
|
||||
/// Shipping-company dictionary code chosen at ship time, if any.
|
||||
pub shipping_company_code: Option<String>,
|
||||
pub status: ShipmentStatus,
|
||||
pub shipped_at: Option<DateTime<Utc>>,
|
||||
pub delivered_at: Option<DateTime<Utc>>,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::error::ApiResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::service::{self, FreightTemplateView, ShippingCompanyRow, TemplateBody};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/shipping/companies", get(list_companies))
|
||||
.route(
|
||||
"/shop/freight-templates",
|
||||
get(list_templates).post(create_template),
|
||||
)
|
||||
.route(
|
||||
"/shop/freight-templates/{id}",
|
||||
axum::routing::put(update_template).delete(delete_template),
|
||||
)
|
||||
}
|
||||
|
||||
async fn list_companies(State(state): State<AppState>) -> ApiResult<Json<Vec<ShippingCompanyRow>>> {
|
||||
Ok(Json(service::list_companies(&state).await?))
|
||||
}
|
||||
|
||||
async fn list_templates(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> ApiResult<Json<Vec<FreightTemplateView>>> {
|
||||
let shop_id = auth.require_shop()?;
|
||||
Ok(Json(service::list(&state, shop_id).await?))
|
||||
}
|
||||
|
||||
async fn create_template(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(body): Json<TemplateBody>,
|
||||
) -> ApiResult<(StatusCode, Json<FreightTemplateView>)> {
|
||||
let shop_id = auth.require_shop()?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(service::create(&state, shop_id, body).await?),
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_template(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<TemplateBody>,
|
||||
) -> ApiResult<Json<FreightTemplateView>> {
|
||||
let shop_id = auth.require_shop()?;
|
||||
Ok(Json(service::update(&state, shop_id, id, body).await?))
|
||||
}
|
||||
|
||||
async fn delete_template(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
let shop_id = auth.require_shop()?;
|
||||
service::delete(&state, shop_id, id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod handlers;
|
||||
pub mod service;
|
||||
|
||||
pub use handlers::router;
|
||||
@@ -0,0 +1,476 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sqlx::PgConnection;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::models::{Currency, FreightPricingMethod};
|
||||
use crate::modules::order::AddressBody;
|
||||
use crate::money::convert_minor;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct FreightTemplateRow {
|
||||
pub id: Uuid,
|
||||
pub shop_id: Uuid,
|
||||
pub name: String,
|
||||
pub is_default: bool,
|
||||
pub always_free: bool,
|
||||
pub pricing_method: FreightPricingMethod,
|
||||
pub first_fee_minor: i64,
|
||||
pub first_unit: i32,
|
||||
pub additional_fee_minor: i64,
|
||||
pub additional_unit: i32,
|
||||
pub free_threshold_minor: Option<i64>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct RegionRuleRow {
|
||||
pub id: Uuid,
|
||||
pub template_id: Uuid,
|
||||
pub regions: Vec<String>,
|
||||
pub first_fee_minor: i64,
|
||||
pub first_unit: i32,
|
||||
pub additional_fee_minor: i64,
|
||||
pub additional_unit: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FreightTemplateView {
|
||||
#[serde(flatten)]
|
||||
pub template: FreightTemplateRow,
|
||||
pub region_rules: Vec<RegionRuleRow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct ShippingCompanyRow {
|
||||
pub code: String,
|
||||
pub name: Value,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegionRuleInput {
|
||||
pub regions: Vec<String>,
|
||||
pub first_fee_minor: i64,
|
||||
pub first_unit: i32,
|
||||
pub additional_fee_minor: i64,
|
||||
pub additional_unit: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TemplateBody {
|
||||
pub name: String,
|
||||
pub is_default: Option<bool>,
|
||||
pub always_free: Option<bool>,
|
||||
pub pricing_method: FreightPricingMethod,
|
||||
pub first_fee_minor: i64,
|
||||
pub first_unit: i32,
|
||||
pub additional_fee_minor: i64,
|
||||
pub additional_unit: i32,
|
||||
pub free_threshold_minor: Option<i64>,
|
||||
pub region_rules: Option<Vec<RegionRuleInput>>,
|
||||
}
|
||||
|
||||
const TEMPLATE_COLS: &str = "id, shop_id, name, is_default, always_free, pricing_method,
|
||||
first_fee_minor, first_unit, additional_fee_minor, additional_unit, free_threshold_minor,
|
||||
created_at, updated_at";
|
||||
const RULE_COLS: &str =
|
||||
"id, template_id, regions, first_fee_minor, first_unit, additional_fee_minor, additional_unit";
|
||||
|
||||
fn validate(body: &TemplateBody) -> ApiResult<()> {
|
||||
if body.name.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest("name is required".into()));
|
||||
}
|
||||
for (field, value) in [
|
||||
("first_fee_minor", body.first_fee_minor),
|
||||
("additional_fee_minor", body.additional_fee_minor),
|
||||
] {
|
||||
if value < 0 {
|
||||
return Err(ApiError::BadRequest(format!("{field} cannot be negative")));
|
||||
}
|
||||
}
|
||||
for (field, value) in [("first_unit", body.first_unit), ("additional_unit", body.additional_unit)] {
|
||||
if value <= 0 {
|
||||
return Err(ApiError::BadRequest(format!("{field} must be positive")));
|
||||
}
|
||||
}
|
||||
if let Some(threshold) = body.free_threshold_minor {
|
||||
if threshold <= 0 {
|
||||
return Err(ApiError::BadRequest("free_threshold_minor must be positive".into()));
|
||||
}
|
||||
}
|
||||
for rule in body.region_rules.as_deref().unwrap_or(&[]) {
|
||||
if rule.regions.is_empty() || rule.regions.iter().any(|r| r.trim().is_empty()) {
|
||||
return Err(ApiError::BadRequest("rule regions must be non-empty".into()));
|
||||
}
|
||||
if rule.first_fee_minor < 0 || rule.additional_fee_minor < 0 {
|
||||
return Err(ApiError::BadRequest("rule fees cannot be negative".into()));
|
||||
}
|
||||
if rule.first_unit <= 0 || rule.additional_unit <= 0 {
|
||||
return Err(ApiError::BadRequest("rule units must be positive".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn with_rules(
|
||||
db: &mut PgConnection,
|
||||
templates: Vec<FreightTemplateRow>,
|
||||
) -> ApiResult<Vec<FreightTemplateView>> {
|
||||
let ids: Vec<Uuid> = templates.iter().map(|t| t.id).collect();
|
||||
let rules = sqlx::query_as::<_, RegionRuleRow>(&format!(
|
||||
"SELECT {RULE_COLS} FROM freight_region_rules WHERE template_id = ANY($1) ORDER BY id"
|
||||
))
|
||||
.bind(&ids)
|
||||
.fetch_all(&mut *db)
|
||||
.await?;
|
||||
Ok(templates
|
||||
.into_iter()
|
||||
.map(|template| FreightTemplateView {
|
||||
region_rules: rules
|
||||
.iter()
|
||||
.filter(|r| r.template_id == template.id)
|
||||
.map(|r| RegionRuleRow {
|
||||
id: r.id,
|
||||
template_id: r.template_id,
|
||||
regions: r.regions.clone(),
|
||||
first_fee_minor: r.first_fee_minor,
|
||||
first_unit: r.first_unit,
|
||||
additional_fee_minor: r.additional_fee_minor,
|
||||
additional_unit: r.additional_unit,
|
||||
})
|
||||
.collect(),
|
||||
template,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list(state: &AppState, shop_id: Uuid) -> ApiResult<Vec<FreightTemplateView>> {
|
||||
let mut conn = state.db.acquire().await?;
|
||||
let templates = sqlx::query_as::<_, FreightTemplateRow>(&format!(
|
||||
"SELECT {TEMPLATE_COLS} FROM freight_templates WHERE shop_id = $1 ORDER BY created_at"
|
||||
))
|
||||
.bind(shop_id)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
with_rules(&mut conn, templates).await
|
||||
}
|
||||
|
||||
async fn replace_rules(
|
||||
tx: &mut PgConnection,
|
||||
template_id: Uuid,
|
||||
rules: &[RegionRuleInput],
|
||||
) -> ApiResult<()> {
|
||||
sqlx::query("DELETE FROM freight_region_rules WHERE template_id = $1")
|
||||
.bind(template_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
for rule in rules {
|
||||
sqlx::query(&format!(
|
||||
"INSERT INTO freight_region_rules (template_id, regions, first_fee_minor, first_unit,
|
||||
additional_fee_minor, additional_unit)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)"
|
||||
))
|
||||
.bind(template_id)
|
||||
.bind(&rule.regions)
|
||||
.bind(rule.first_fee_minor)
|
||||
.bind(rule.first_unit)
|
||||
.bind(rule.additional_fee_minor)
|
||||
.bind(rule.additional_unit)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Setting `is_default` clears any other default of the same shop first, so
|
||||
/// the partial unique index never trips under sequential API writes.
|
||||
async fn clear_default(tx: &mut PgConnection, shop_id: Uuid) -> ApiResult<()> {
|
||||
sqlx::query("UPDATE freight_templates SET is_default = false WHERE shop_id = $1 AND is_default")
|
||||
.bind(shop_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
state: &AppState,
|
||||
shop_id: Uuid,
|
||||
body: TemplateBody,
|
||||
) -> ApiResult<FreightTemplateView> {
|
||||
validate(&body)?;
|
||||
let mut tx = state.db.begin().await?;
|
||||
if body.is_default.unwrap_or(false) {
|
||||
clear_default(&mut tx, shop_id).await?;
|
||||
}
|
||||
let id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO freight_templates (shop_id, name, is_default, always_free, pricing_method,
|
||||
first_fee_minor, first_unit, additional_fee_minor,
|
||||
additional_unit, free_threshold_minor)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id",
|
||||
)
|
||||
.bind(shop_id)
|
||||
.bind(body.name.trim())
|
||||
.bind(body.is_default.unwrap_or(false))
|
||||
.bind(body.always_free.unwrap_or(false))
|
||||
.bind(body.pricing_method)
|
||||
.bind(body.first_fee_minor)
|
||||
.bind(body.first_unit)
|
||||
.bind(body.additional_fee_minor)
|
||||
.bind(body.additional_unit)
|
||||
.bind(body.free_threshold_minor)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if let Some(rules) = &body.region_rules {
|
||||
replace_rules(&mut tx, id, rules).await?;
|
||||
}
|
||||
let template = sqlx::query_as::<_, FreightTemplateRow>(&format!(
|
||||
"SELECT {TEMPLATE_COLS} FROM freight_templates WHERE id = $1"
|
||||
))
|
||||
.bind(id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
let mut views = with_rules(&mut tx, vec![template]).await?;
|
||||
tx.commit().await?;
|
||||
Ok(views.remove(0))
|
||||
}
|
||||
|
||||
async fn get_for_shop(
|
||||
tx: &mut PgConnection,
|
||||
shop_id: Uuid,
|
||||
id: Uuid,
|
||||
) -> ApiResult<FreightTemplateRow> {
|
||||
sqlx::query_as::<_, FreightTemplateRow>(&format!(
|
||||
"SELECT {TEMPLATE_COLS} FROM freight_templates WHERE id = $1 AND shop_id = $2"
|
||||
))
|
||||
.bind(id)
|
||||
.bind(shop_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("freight template".into()))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
state: &AppState,
|
||||
shop_id: Uuid,
|
||||
id: Uuid,
|
||||
body: TemplateBody,
|
||||
) -> ApiResult<FreightTemplateView> {
|
||||
validate(&body)?;
|
||||
let mut tx = state.db.begin().await?;
|
||||
get_for_shop(&mut tx, shop_id, id).await?;
|
||||
if body.is_default.unwrap_or(false) {
|
||||
clear_default(&mut tx, shop_id).await?;
|
||||
}
|
||||
sqlx::query(
|
||||
"UPDATE freight_templates SET name = $2, is_default = $3, always_free = $4,
|
||||
pricing_method = $5, first_fee_minor = $6, first_unit = $7,
|
||||
additional_fee_minor = $8, additional_unit = $9, free_threshold_minor = $10,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND shop_id = $11",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(body.name.trim())
|
||||
.bind(body.is_default.unwrap_or(false))
|
||||
.bind(body.always_free.unwrap_or(false))
|
||||
.bind(body.pricing_method)
|
||||
.bind(body.first_fee_minor)
|
||||
.bind(body.first_unit)
|
||||
.bind(body.additional_fee_minor)
|
||||
.bind(body.additional_unit)
|
||||
.bind(body.free_threshold_minor)
|
||||
.bind(shop_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
replace_rules(&mut tx, id, body.region_rules.as_deref().unwrap_or(&[])).await?;
|
||||
let template = get_for_shop(&mut tx, shop_id, id).await?;
|
||||
let mut views = with_rules(&mut tx, vec![template]).await?;
|
||||
tx.commit().await?;
|
||||
Ok(views.remove(0))
|
||||
}
|
||||
|
||||
pub async fn delete(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult<()> {
|
||||
let result = sqlx::query("DELETE FROM freight_templates WHERE id = $1 AND shop_id = $2")
|
||||
.bind(id)
|
||||
.bind(shop_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::NotFound("freight template".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_companies(state: &AppState) -> ApiResult<Vec<ShippingCompanyRow>> {
|
||||
Ok(sqlx::query_as::<_, ShippingCompanyRow>(
|
||||
"SELECT code, name, active FROM shipping_companies WHERE active ORDER BY code",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn company_exists(tx: &mut PgConnection, code: &str) -> ApiResult<bool> {
|
||||
Ok(sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM shipping_companies WHERE code = $1 AND active)")
|
||||
.bind(code)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?)
|
||||
}
|
||||
|
||||
// --- calculation ---
|
||||
|
||||
/// One checkout line reduced to what shipping needs.
|
||||
pub struct CalcLine {
|
||||
pub product_id: Uuid,
|
||||
pub qty: i32,
|
||||
/// Merchandise subtotal of this line in the target currency.
|
||||
pub line_total_minor: i64,
|
||||
pub weight_grams: Option<i32>,
|
||||
/// The SKU's own currency; template fees are interpreted in it.
|
||||
pub currency: String,
|
||||
}
|
||||
|
||||
struct ResolvedLine<'a> {
|
||||
template: Option<&'a FreightTemplateRow>,
|
||||
line: &'a CalcLine,
|
||||
}
|
||||
|
||||
/// Resolve each line's template (product first, shop default second), group by
|
||||
/// template, and compute the shop-order fee in the target currency.
|
||||
pub async fn shop_fee(
|
||||
tx: &mut PgConnection,
|
||||
shop_id: Uuid,
|
||||
lines: &[CalcLine],
|
||||
address: &AddressBody,
|
||||
target: &Currency,
|
||||
all_currencies: &[Currency],
|
||||
) -> ApiResult<i64> {
|
||||
let templates = sqlx::query_as::<_, FreightTemplateRow>(&format!(
|
||||
"SELECT {TEMPLATE_COLS} FROM freight_templates WHERE shop_id = $1"
|
||||
))
|
||||
.bind(shop_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
if templates.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let ids: Vec<Uuid> = templates.iter().map(|t| t.id).collect();
|
||||
let rules = sqlx::query_as::<_, RegionRuleRow>(&format!(
|
||||
"SELECT {RULE_COLS} FROM freight_region_rules WHERE template_id = ANY($1)"
|
||||
))
|
||||
.bind(&ids)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
let default_id = templates.iter().find(|t| t.is_default).map(|t| t.id);
|
||||
|
||||
// Per-product template links for this shop's products.
|
||||
let product_ids: Vec<Uuid> = lines.iter().map(|l| l.product_id).collect();
|
||||
let links: Vec<(Uuid, Option<Uuid>)> = sqlx::query_as(
|
||||
"SELECT id, freight_template_id FROM products WHERE id = ANY($1) AND shop_id = $2",
|
||||
)
|
||||
.bind(&product_ids)
|
||||
.bind(shop_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
let link_of = |product_id: Uuid| {
|
||||
links
|
||||
.iter()
|
||||
.find(|(pid, _)| *pid == product_id)
|
||||
.and_then(|(_, tid)| *tid)
|
||||
};
|
||||
|
||||
let mut resolved: Vec<ResolvedLine> = Vec::with_capacity(lines.len());
|
||||
for line in lines {
|
||||
let template_id = link_of(line.product_id).or(default_id);
|
||||
let template = template_id.and_then(|tid| templates.iter().find(|t| t.id == tid));
|
||||
resolved.push(ResolvedLine {
|
||||
template,
|
||||
line,
|
||||
});
|
||||
}
|
||||
|
||||
let mut total = 0i64;
|
||||
for template in templates.iter() {
|
||||
let group: Vec<&ResolvedLine> = resolved
|
||||
.iter()
|
||||
.filter(|r| r.template.is_some_and(|t| t.id == template.id))
|
||||
.collect();
|
||||
if group.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if template.always_free {
|
||||
continue;
|
||||
}
|
||||
let subtotal: i64 = group.iter().map(|r| r.line.line_total_minor).sum();
|
||||
if template
|
||||
.free_threshold_minor
|
||||
.is_some_and(|threshold| subtotal >= threshold)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let rule = rules.iter().find(|r| {
|
||||
r.template_id == template.id
|
||||
&& r.regions.iter().any(|region| {
|
||||
let needle = region.trim().to_lowercase();
|
||||
!needle.is_empty()
|
||||
&& [address.region.as_str(), address.city.as_str(), address.country.as_str()]
|
||||
.iter()
|
||||
.any(|field| field.to_lowercase().contains(&needle))
|
||||
})
|
||||
});
|
||||
let (first_fee, first_unit, additional_fee, additional_unit) = match rule {
|
||||
Some(r) => (
|
||||
r.first_fee_minor,
|
||||
r.first_unit,
|
||||
r.additional_fee_minor,
|
||||
r.additional_unit,
|
||||
),
|
||||
None => (
|
||||
template.first_fee_minor,
|
||||
template.first_unit,
|
||||
template.additional_fee_minor,
|
||||
template.additional_unit,
|
||||
),
|
||||
};
|
||||
let units: i64 = match template.pricing_method {
|
||||
FreightPricingMethod::ByPiece => group.iter().map(|r| r.line.qty as i64).sum(),
|
||||
FreightPricingMethod::ByWeight => group
|
||||
.iter()
|
||||
.map(|r| r.line.qty as i64 * r.line.weight_grams.unwrap_or(0) as i64)
|
||||
.sum(),
|
||||
};
|
||||
let extra = (units - first_unit as i64).max(0);
|
||||
let extra_units = (extra + additional_unit as i64 - 1) / additional_unit as i64;
|
||||
let fee = first_fee + extra_units * additional_fee;
|
||||
// Template fees are minor units of the group's SKU currency.
|
||||
let from = all_currencies
|
||||
.iter()
|
||||
.find(|c| c.code == group[0].line.currency)
|
||||
.ok_or_else(|| ApiError::BadRequest("group currency disabled".into()))?;
|
||||
total += convert_minor(fee, from, target)?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Resolve one line's template + method for the order-item snapshot.
|
||||
pub async fn resolve_line_template(
|
||||
tx: &mut PgConnection,
|
||||
shop_id: Uuid,
|
||||
product_id: Uuid,
|
||||
) -> ApiResult<Option<(Uuid, FreightPricingMethod)>> {
|
||||
sqlx::query_as::<_, (Uuid, FreightPricingMethod)>(
|
||||
"SELECT t.id, t.pricing_method FROM freight_templates t
|
||||
WHERE t.id = COALESCE(
|
||||
(SELECT freight_template_id FROM products WHERE id = $1 AND shop_id = $2),
|
||||
(SELECT id FROM freight_templates WHERE shop_id = $2 AND is_default)
|
||||
)",
|
||||
)
|
||||
.bind(product_id)
|
||||
.bind(shop_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(ApiError::from)
|
||||
}
|
||||
@@ -27,13 +27,15 @@ pub struct ShipmentItemBody {
|
||||
pub struct ShipmentBody {
|
||||
pub carrier: String,
|
||||
pub tracking_no: String,
|
||||
/// Shipping-company dictionary code; validated against the dictionary.
|
||||
pub shipping_company_code: Option<String>,
|
||||
pub items: Vec<ShipmentItemBody>,
|
||||
}
|
||||
|
||||
const SHIPMENT_COLS: &str = "id, shipment_no, order_id, carrier, tracking_no, status,
|
||||
shipped_at, delivered_at, created_at";
|
||||
const SHIPMENT_S: &str = "s.id, s.shipment_no, s.order_id, s.carrier, s.tracking_no, s.status,
|
||||
s.shipped_at, s.delivered_at, s.created_at";
|
||||
const SHIPMENT_COLS: &str = "id, shipment_no, order_id, carrier, tracking_no, shipping_company_code,
|
||||
status, shipped_at, delivered_at, created_at";
|
||||
const SHIPMENT_S: &str = "s.id, s.shipment_no, s.order_id, s.carrier, s.tracking_no,
|
||||
s.shipping_company_code, s.status, s.shipped_at, s.delivered_at, s.created_at";
|
||||
|
||||
async fn views(db: &PgPool, shipments: Vec<Shipment>) -> ApiResult<Vec<ShipmentView>> {
|
||||
let ids: Vec<Uuid> = shipments.iter().map(|s| s.id).collect();
|
||||
@@ -156,15 +158,23 @@ pub async fn create(
|
||||
)));
|
||||
}
|
||||
}
|
||||
if let Some(code) = &body.shipping_company_code {
|
||||
if !crate::modules::freight::service::company_exists(&mut tx, code.trim()).await? {
|
||||
return Err(ApiError::BadRequest(
|
||||
"shipping_company_code is not in the dictionary".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let shipment = sqlx::query_as::<_, Shipment>(&format!(
|
||||
"INSERT INTO shipments (shipment_no, order_id, carrier, tracking_no)
|
||||
"INSERT INTO shipments (shipment_no, order_id, carrier, tracking_no, shipping_company_code)
|
||||
VALUES ('SH' || to_char(now(), 'YYMMDD') || lpad(nextval('shipment_no_seq')::text, 6, '0'),
|
||||
$1, $2, $3)
|
||||
$1, $2, $3, $4)
|
||||
RETURNING {SHIPMENT_COLS}"
|
||||
))
|
||||
.bind(order.id)
|
||||
.bind(body.carrier.trim())
|
||||
.bind(body.tracking_no.trim())
|
||||
.bind(body.shipping_company_code.as_deref().map(str::trim))
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
for item in &body.items {
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod brand;
|
||||
pub mod cart;
|
||||
pub mod category;
|
||||
pub mod content;
|
||||
pub mod freight;
|
||||
pub mod coupon;
|
||||
pub mod currency;
|
||||
pub mod favorite;
|
||||
@@ -40,6 +41,7 @@ pub fn api_router() -> Router<AppState> {
|
||||
.merge(favorite::router())
|
||||
.merge(flash_sale::router())
|
||||
.merge(group_buying::router())
|
||||
.merge(freight::router())
|
||||
.merge(order::router())
|
||||
.merge(points::router())
|
||||
.merge(shop::router())
|
||||
|
||||
@@ -40,6 +40,19 @@ impl AddressBody {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct QuoteShop {
|
||||
pub shop_id: uuid::Uuid,
|
||||
pub fee_minor: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ShippingQuoteView {
|
||||
pub currency: String,
|
||||
pub shops: Vec<QuoteShop>,
|
||||
pub total_minor: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum OrderScope {
|
||||
User(uuid::Uuid),
|
||||
|
||||
@@ -13,13 +13,14 @@ use crate::http::{PageQuery, Paged};
|
||||
use crate::models::OrderStatus;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::dto::{AddressBody, OrderScope, OrderView};
|
||||
use super::dto::{AddressBody, OrderScope, OrderView, ShippingQuoteView};
|
||||
use super::service;
|
||||
|
||||
pub fn customer_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/orders", get(list_my_orders))
|
||||
.route("/orders/checkout", post(checkout))
|
||||
.route("/orders/shipping-quote", post(shipping_quote))
|
||||
.route("/orders/{id}", get(get_order))
|
||||
.route("/orders/{id}/pay", post(pay_order))
|
||||
.route("/orders/{id}/cancel", post(cancel_order))
|
||||
@@ -53,6 +54,22 @@ async fn get_order(
|
||||
Ok(Json(service::get_for_user(&state, auth.id, id).await?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QuoteBody {
|
||||
shipping_address: AddressBody,
|
||||
currency: String,
|
||||
}
|
||||
|
||||
async fn shipping_quote(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(body): Json<QuoteBody>,
|
||||
) -> ApiResult<Json<ShippingQuoteView>> {
|
||||
Ok(Json(
|
||||
service::shipping_quote(&state, auth.id, body.shipping_address, body.currency).await?,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CheckoutBody {
|
||||
shipping_address: AddressBody,
|
||||
|
||||
@@ -146,6 +146,7 @@ pub async fn insert_order(
|
||||
currency: &str,
|
||||
total: i64,
|
||||
discount_minor: i64,
|
||||
shipping_fee_minor: i64,
|
||||
coupon_id: Option<Uuid>,
|
||||
group_activity_id: Option<Uuid>,
|
||||
group_id: Option<Uuid>,
|
||||
@@ -153,9 +154,9 @@ pub async fn insert_order(
|
||||
) -> ApiResult<Order> {
|
||||
Ok(sqlx::query_as::<_, Order>(&format!(
|
||||
"INSERT INTO orders (order_no, shop_id, user_id, currency, total_minor, discount_minor,
|
||||
coupon_id, group_activity_id, group_id, shipping_address)
|
||||
shipping_fee_minor, coupon_id, group_activity_id, group_id, shipping_address)
|
||||
VALUES ('VM' || to_char(now(), 'YYMMDD') || lpad(nextval('order_no_seq')::text, 6, '0'),
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING {ORDER_COLS}"
|
||||
))
|
||||
.bind(shop_id)
|
||||
@@ -163,6 +164,7 @@ pub async fn insert_order(
|
||||
.bind(currency)
|
||||
.bind(total)
|
||||
.bind(discount_minor)
|
||||
.bind(shipping_fee_minor)
|
||||
.bind(coupon_id)
|
||||
.bind(group_activity_id)
|
||||
.bind(group_id)
|
||||
@@ -182,10 +184,13 @@ pub async fn insert_item(
|
||||
unit: i64,
|
||||
qty: i32,
|
||||
flash_sale_item_id: Option<Uuid>,
|
||||
freight_template_id: Option<Uuid>,
|
||||
freight_pricing_method: Option<crate::models::FreightPricingMethod>,
|
||||
) -> ApiResult<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO order_items (order_id, sku_id, product_name, sku_code, image, unit_price_minor, qty, flash_sale_item_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
|
||||
"INSERT INTO order_items (order_id, sku_id, product_name, sku_code, image, unit_price_minor, qty,
|
||||
flash_sale_item_id, freight_template_id, freight_pricing_method)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
||||
)
|
||||
.bind(order_id)
|
||||
.bind(sku_id)
|
||||
@@ -195,6 +200,8 @@ pub async fn insert_item(
|
||||
.bind(unit)
|
||||
.bind(qty)
|
||||
.bind(flash_sale_item_id)
|
||||
.bind(freight_template_id)
|
||||
.bind(freight_pricing_method)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -339,12 +346,14 @@ pub async fn maybe_mark_order_completed(db: &PgPool, order_id: Uuid) -> ApiResul
|
||||
pub struct CheckoutRow {
|
||||
pub sku_id: Uuid,
|
||||
pub shop_id: Uuid,
|
||||
pub product_id: Uuid,
|
||||
pub product_name: serde_json::Value,
|
||||
pub sku_code: String,
|
||||
pub image: Option<String>,
|
||||
pub price_minor: i64,
|
||||
pub currency: String,
|
||||
pub stock: i32,
|
||||
pub weight_grams: Option<i32>,
|
||||
}
|
||||
|
||||
pub async fn lock_purchasable_skus(
|
||||
@@ -352,8 +361,8 @@ pub async fn lock_purchasable_skus(
|
||||
sku_ids: &[Uuid],
|
||||
) -> ApiResult<Vec<CheckoutRow>> {
|
||||
Ok(sqlx::query_as::<_, CheckoutRow>(
|
||||
"SELECT s.id AS sku_id, p.shop_id, p.name AS product_name, s.sku_code,
|
||||
(p.images ->> 0) AS image, s.price_minor, s.currency, s.stock
|
||||
"SELECT s.id AS sku_id, p.shop_id, p.id AS product_id, p.name AS product_name, s.sku_code,
|
||||
(p.images ->> 0) AS image, s.price_minor, s.currency, s.stock, s.weight_grams
|
||||
FROM skus s
|
||||
JOIN products p ON p.id = s.product_id
|
||||
JOIN shops sh ON sh.id = p.shop_id
|
||||
@@ -367,6 +376,23 @@ pub async fn lock_purchasable_skus(
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Same purchasable projection without row locks, for shipping quotes.
|
||||
pub async fn purchasable_skus(db: &PgPool, sku_ids: &[Uuid]) -> ApiResult<Vec<CheckoutRow>> {
|
||||
Ok(sqlx::query_as::<_, CheckoutRow>(
|
||||
"SELECT s.id AS sku_id, p.shop_id, p.id AS product_id, p.name AS product_name, s.sku_code,
|
||||
(p.images ->> 0) AS image, s.price_minor, s.currency, s.stock, s.weight_grams
|
||||
FROM skus s
|
||||
JOIN products p ON p.id = s.product_id
|
||||
JOIN shops sh ON sh.id = p.shop_id
|
||||
WHERE s.id = ANY($1) AND s.active = TRUE
|
||||
AND p.status = 'published' AND sh.status = 'active'
|
||||
ORDER BY s.id",
|
||||
)
|
||||
.bind(sku_ids)
|
||||
.fetch_all(db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn enabled_currency(
|
||||
tx: &mut PgConnection,
|
||||
code: &str,
|
||||
|
||||
@@ -6,11 +6,11 @@ use crate::error::{ApiError, ApiResult};
|
||||
use crate::http::{clamp_page, clamp_per_page, Paged};
|
||||
use crate::models::OrderStatus;
|
||||
use crate::modules::group_buying::GroupBuyIntent;
|
||||
use crate::modules::{cart, coupon, flash_sale, group_buying};
|
||||
use crate::modules::{cart, coupon, flash_sale, freight, group_buying};
|
||||
use crate::money::convert_minor;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::dto::{AddressBody, OrderScope, OrderView};
|
||||
use super::dto::{AddressBody, OrderScope, OrderView, QuoteShop, ShippingQuoteView};
|
||||
use super::repo;
|
||||
|
||||
/// One cart line after activity resolution: the standard-priced part and, when
|
||||
@@ -19,9 +19,12 @@ use super::repo;
|
||||
struct CheckoutLine {
|
||||
shop_id: Uuid,
|
||||
sku_id: Uuid,
|
||||
product_id: Uuid,
|
||||
product_name: serde_json::Value,
|
||||
sku_code: String,
|
||||
image: Option<String>,
|
||||
currency: String,
|
||||
weight_grams: Option<i32>,
|
||||
normal_unit: i64,
|
||||
normal_qty: i32,
|
||||
activity_item: Option<Uuid>,
|
||||
@@ -161,9 +164,12 @@ pub async fn checkout(
|
||||
lines.push(CheckoutLine {
|
||||
shop_id: row.shop_id,
|
||||
sku_id: row.sku_id,
|
||||
product_id: row.product_id,
|
||||
product_name: row.product_name.clone(),
|
||||
sku_code: row.sku_code.clone(),
|
||||
image: row.image.clone(),
|
||||
currency: row.currency.clone(),
|
||||
weight_grams: row.weight_grams,
|
||||
normal_unit,
|
||||
normal_qty: qty - activity_qty,
|
||||
activity_item,
|
||||
@@ -263,20 +269,57 @@ pub async fn checkout(
|
||||
None => 0,
|
||||
};
|
||||
|
||||
// Shipping is computed server-side from the destination; client
|
||||
// amounts are never read.
|
||||
let calc_lines: Vec<freight::service::CalcLine> = shop_lines
|
||||
.iter()
|
||||
.map(|line| freight::service::CalcLine {
|
||||
product_id: line.product_id,
|
||||
qty: line.normal_qty + line.activity_qty,
|
||||
line_total_minor: line.line_total(),
|
||||
weight_grams: line.weight_grams,
|
||||
currency: line.currency.clone(),
|
||||
})
|
||||
.collect();
|
||||
let shipping_fee = freight::service::shop_fee(
|
||||
&mut tx,
|
||||
shop_id,
|
||||
&calc_lines,
|
||||
&shipping_address,
|
||||
&target,
|
||||
&all_currencies,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let group = group_by_shop.get(&shop_id);
|
||||
let order = repo::insert_order(
|
||||
&mut tx,
|
||||
shop_id,
|
||||
user_id,
|
||||
&target.code,
|
||||
subtotal - discount,
|
||||
subtotal - discount + shipping_fee,
|
||||
discount,
|
||||
shipping_fee,
|
||||
selected_coupon,
|
||||
group.map(|g| g.activity_id),
|
||||
group.map(|g| g.group_id),
|
||||
&address,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Snapshot the resolved freight template per product line.
|
||||
let mut line_freight: HashMap<Uuid, (Uuid, crate::models::FreightPricingMethod)> =
|
||||
HashMap::new();
|
||||
for line in &shop_lines {
|
||||
if line_freight.contains_key(&line.product_id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(resolved) =
|
||||
freight::service::resolve_line_template(&mut tx, shop_id, line.product_id).await?
|
||||
{
|
||||
line_freight.insert(line.product_id, resolved);
|
||||
}
|
||||
}
|
||||
if let Some(group) = group {
|
||||
if group.opened {
|
||||
// The pending order that opened the group can later cancel it.
|
||||
@@ -292,6 +335,7 @@ pub async fn checkout(
|
||||
repo::insert_item(
|
||||
&mut tx,
|
||||
order.id,
|
||||
|
||||
line.sku_id,
|
||||
&line.product_name,
|
||||
&line.sku_code,
|
||||
@@ -299,6 +343,8 @@ pub async fn checkout(
|
||||
line.activity_unit,
|
||||
line.activity_qty,
|
||||
Some(item_id),
|
||||
line_freight.get(&line.product_id).copied().map(|(id, _)| id),
|
||||
line_freight.get(&line.product_id).copied().map(|(_, m)| m),
|
||||
)
|
||||
.await?;
|
||||
flash_sale::service::consume(&mut tx, item_id, line.activity_qty).await?;
|
||||
@@ -314,6 +360,8 @@ pub async fn checkout(
|
||||
line.normal_unit,
|
||||
line.normal_qty,
|
||||
None,
|
||||
line_freight.get(&line.product_id).copied().map(|(id, _)| id),
|
||||
line_freight.get(&line.product_id).copied().map(|(_, m)| m),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -330,6 +378,83 @@ pub async fn checkout(
|
||||
repo::attach_items(&state.db, created).await
|
||||
}
|
||||
|
||||
/// Read-only per-shop shipping quote for the current cart and destination.
|
||||
/// Normal prices only: flash-sale pricing never changes the shipping rules,
|
||||
/// and the quote is recomputed authoritatively at checkout anyway.
|
||||
pub async fn shipping_quote(
|
||||
state: &AppState,
|
||||
user_id: Uuid,
|
||||
address: AddressBody,
|
||||
currency: String,
|
||||
) -> ApiResult<ShippingQuoteView> {
|
||||
address.validate()?;
|
||||
let entries = cart::service::read_entries(state, user_id).await?;
|
||||
if entries.is_empty() {
|
||||
return Err(ApiError::BadRequest("cart is empty".into()));
|
||||
}
|
||||
let mut conn = state.db.acquire().await?;
|
||||
let target = repo::enabled_currency(&mut conn, ¤cy.to_uppercase()).await?;
|
||||
let all_currencies = repo::all_enabled_currencies(&mut conn).await?;
|
||||
let sku_ids: Vec<Uuid> = entries.iter().map(|(id, _)| *id).collect();
|
||||
let rows = repo::purchasable_skus(&state.db, &sku_ids).await?;
|
||||
if rows.len() != entries.len() {
|
||||
return Err(ApiError::Conflict(
|
||||
"some cart items are no longer purchasable".into(),
|
||||
));
|
||||
}
|
||||
let qty_by_sku: HashMap<Uuid, i32> = entries.iter().copied().collect();
|
||||
|
||||
let mut shop_ids: Vec<Uuid> = Vec::new();
|
||||
for row in &rows {
|
||||
if !shop_ids.contains(&row.shop_id) {
|
||||
shop_ids.push(row.shop_id);
|
||||
}
|
||||
}
|
||||
let mut shops = Vec::with_capacity(shop_ids.len());
|
||||
let mut total = 0i64;
|
||||
for shop_id in shop_ids {
|
||||
let calc_lines: Vec<freight::service::CalcLine> = rows
|
||||
.iter()
|
||||
.filter(|row| row.shop_id == shop_id)
|
||||
.map(|row| {
|
||||
let qty = qty_by_sku.get(&row.sku_id).copied().unwrap_or(0);
|
||||
let from = all_currencies
|
||||
.iter()
|
||||
.find(|c| c.code == row.currency)
|
||||
.expect("purchasable SKU currency is enabled");
|
||||
freight::service::CalcLine {
|
||||
product_id: row.product_id,
|
||||
qty,
|
||||
line_total_minor: convert_minor(row.price_minor, from, &target)
|
||||
.map(|unit| unit * qty as i64)
|
||||
.unwrap_or(0),
|
||||
weight_grams: row.weight_grams,
|
||||
currency: row.currency.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let fee = freight::service::shop_fee(
|
||||
&mut conn,
|
||||
shop_id,
|
||||
&calc_lines,
|
||||
&address,
|
||||
&target,
|
||||
&all_currencies,
|
||||
)
|
||||
.await?;
|
||||
total += fee;
|
||||
shops.push(QuoteShop {
|
||||
shop_id,
|
||||
fee_minor: fee,
|
||||
});
|
||||
}
|
||||
Ok(ShippingQuoteView {
|
||||
currency: target.code,
|
||||
shops,
|
||||
total_minor: total,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn pay(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult<OrderView> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let order = repo::lock_for_user(&mut tx, user_id, id).await?;
|
||||
|
||||
@@ -55,6 +55,8 @@ pub struct ProductBody {
|
||||
pub category_id: Option<Uuid>,
|
||||
pub brand_id: Option<Uuid>,
|
||||
pub slug: String,
|
||||
/// Freight template of the same shop; overrides the shop default.
|
||||
pub freight_template_id: Option<Uuid>,
|
||||
pub name: serde_json::Value,
|
||||
pub description: Option<serde_json::Value>,
|
||||
pub images: Option<serde_json::Value>,
|
||||
|
||||
@@ -19,7 +19,7 @@ const MIN_PRICE: &str =
|
||||
"(SELECT MIN(price_minor) FROM skus WHERE product_id = p.id AND active = TRUE)";
|
||||
|
||||
const PRODUCT_COLS: &str = "p.id, p.shop_id, p.category_id, p.brand_id, p.slug, p.name,
|
||||
p.description, p.images, p.status, p.created_at, p.updated_at";
|
||||
p.description, p.images, p.status, p.freight_template_id, p.created_at, p.updated_at";
|
||||
|
||||
pub async fn list_public(
|
||||
state: &AppState,
|
||||
@@ -108,7 +108,7 @@ pub async fn get_public(state: &AppState, id_or_slug: &str) -> ApiResult<Product
|
||||
|
||||
async fn load_own_product(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult<Product> {
|
||||
sqlx::query_as::<_, Product>(
|
||||
"SELECT id, shop_id, category_id, brand_id, slug, name, description, images, status, created_at, updated_at
|
||||
"SELECT id, shop_id, category_id, brand_id, slug, name, description, images, status, freight_template_id, created_at, updated_at
|
||||
FROM products WHERE id = $1 AND shop_id = $2",
|
||||
)
|
||||
.bind(id)
|
||||
@@ -135,7 +135,7 @@ pub async fn list_shop_products(
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let products = sqlx::query_as::<_, Product>(
|
||||
"SELECT id, shop_id, category_id, brand_id, slug, name, description, images, status, created_at, updated_at
|
||||
"SELECT id, shop_id, category_id, brand_id, slug, name, description, images, status, freight_template_id, created_at, updated_at
|
||||
FROM products
|
||||
WHERE shop_id = $1 AND ($2::product_status IS NULL OR status = $2)
|
||||
ORDER BY created_at DESC LIMIT $3 OFFSET $4",
|
||||
@@ -183,16 +183,41 @@ fn validate_product_body(body: &ProductBody) -> ApiResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
/// The linked freight template must belong to the product's own shop.
|
||||
async fn validate_freight_template(
|
||||
state: &AppState,
|
||||
shop_id: Uuid,
|
||||
template_id: Option<Uuid>,
|
||||
) -> ApiResult<()> {
|
||||
if let Some(id) = template_id {
|
||||
let ok: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM freight_templates WHERE id = $1 AND shop_id = $2)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(shop_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if !ok {
|
||||
return Err(ApiError::BadRequest(
|
||||
"freight template belongs to another shop".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_product(
|
||||
state: &AppState,
|
||||
shop_id: Uuid,
|
||||
body: ProductBody,
|
||||
) -> ApiResult<ProductWithSkus> {
|
||||
validate_product_body(&body)?;
|
||||
validate_freight_template(state, shop_id, body.freight_template_id).await?;
|
||||
let product = sqlx::query_as::<_, Product>(
|
||||
"INSERT INTO products (shop_id, category_id, brand_id, slug, name, description, images)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, shop_id, category_id, brand_id, slug, name, description, images, status, created_at, updated_at",
|
||||
"INSERT INTO products (shop_id, category_id, brand_id, slug, name, description, images, freight_template_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, shop_id, category_id, brand_id, slug, name, description, images, status, freight_template_id, created_at, updated_at",
|
||||
)
|
||||
.bind(shop_id)
|
||||
.bind(body.category_id)
|
||||
@@ -201,6 +226,7 @@ pub async fn create_product(
|
||||
.bind(&body.name)
|
||||
.bind(body.description.unwrap_or_else(|| serde_json::json!({})))
|
||||
.bind(body.images.unwrap_or_else(|| serde_json::json!([])))
|
||||
.bind(body.freight_template_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| unique_conflict(e, "slug already exists in this shop"))?;
|
||||
@@ -216,12 +242,13 @@ pub async fn update_product(
|
||||
) -> ApiResult<ProductWithSkus> {
|
||||
load_own_product(state, shop_id, id).await?;
|
||||
validate_product_body(&body)?;
|
||||
validate_freight_template(state, shop_id, body.freight_template_id).await?;
|
||||
let product = sqlx::query_as::<_, Product>(
|
||||
"UPDATE products
|
||||
SET category_id = $2, brand_id = $3, slug = $4, name = $5, description = $6,
|
||||
images = $7, updated_at = now()
|
||||
images = $7, freight_template_id = $8, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, shop_id, category_id, brand_id, slug, name, description, images, status, created_at, updated_at",
|
||||
RETURNING id, shop_id, category_id, brand_id, slug, name, description, images, status, freight_template_id, created_at, updated_at",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(body.category_id)
|
||||
@@ -230,6 +257,7 @@ pub async fn update_product(
|
||||
.bind(&body.name)
|
||||
.bind(body.description.unwrap_or_else(|| serde_json::json!({})))
|
||||
.bind(body.images.unwrap_or_else(|| serde_json::json!([])))
|
||||
.bind(body.freight_template_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| unique_conflict(e, "slug already exists in this shop"))?;
|
||||
@@ -259,7 +287,7 @@ pub async fn transition(
|
||||
}
|
||||
let updated = sqlx::query_as::<_, Product>(
|
||||
"UPDATE products SET status = $2, updated_at = now() WHERE id = $1
|
||||
RETURNING id, shop_id, category_id, brand_id, slug, name, description, images, status, created_at, updated_at",
|
||||
RETURNING id, shop_id, category_id, brand_id, slug, name, description, images, status, freight_template_id, created_at, updated_at",
|
||||
)
|
||||
.bind(product.id)
|
||||
.bind(target)
|
||||
|
||||
Reference in New Issue
Block a user