From 473d19d0891b247a3d9f8f7d665792626bd388fd Mon Sep 17 00:00:00 2001 From: Chengdong Zhang Date: Thu, 24 Sep 2026 15:03:51 +0800 Subject: [PATCH] feat(freight): shop freight templates, server-side checkout fees, company dictionary (add-freight-templates) --- apps/api/src/models.rs | 4 + apps/api/src/modules/freight/handlers.rs | 70 +++ apps/api/src/modules/freight/mod.rs | 4 + apps/api/src/modules/freight/service.rs | 476 ++++++++++++++++++ apps/api/src/modules/fulfillment/service.rs | 22 +- apps/api/src/modules/mod.rs | 2 + apps/api/src/modules/order/dto.rs | 13 + apps/api/src/modules/order/handlers.rs | 19 +- apps/api/src/modules/order/repo.rs | 38 +- apps/api/src/modules/order/service.rs | 131 ++++- apps/api/src/modules/product/dto.rs | 2 + apps/api/src/modules/product/service.rs | 46 +- apps/api/tests/common/mod.rs | 1 + apps/api/tests/freight.rs | 436 ++++++++++++++++ apps/mall/locales/checkout.ts | 12 + apps/mall/mock/api.ts | 33 +- apps/mall/mock/data.ts | 3 +- apps/mall/pages/checkout/index.vue | 108 +++- apps/mall/pages/checkout/pay.vue | 9 + apps/mall/pages/checkout/success.vue | 34 ++ apps/mall/pages/user/orders/[id].vue | 7 + apps/mall/plugins/api.ts | 2 + apps/shop-admin/app.vue | 6 + apps/shop-admin/components/ProductForm.vue | 18 +- apps/shop-admin/composables/useMoney.ts | 38 +- apps/shop-admin/locales-extra.ts | 97 ++++ apps/shop-admin/pages/freight-templates.vue | 449 +++++++++++++++++ apps/shop-admin/pages/orders/[id].vue | 45 +- apps/shop-admin/pages/products/[id].vue | 36 +- apps/shop-admin/pages/products/new.vue | 22 +- apps/shop-admin/pages/shipments.vue | 19 +- openspec/MIGRATION-PLAN.md | 2 +- .../proposal.md | 0 .../specs/frontend-mall/spec.md | 0 .../specs/frontend-shop-admin/spec.md | 0 .../specs/order/spec.md | 0 .../specs/shipping/spec.md | 0 .../tasks.md | 30 +- openspec/specs/frontend-mall/spec.md | 15 + openspec/specs/frontend-shop-admin/spec.md | 18 + openspec/specs/order/spec.md | 18 + openspec/specs/shipping/spec.md | 84 ++++ packages/shared/src/api.ts | 25 +- packages/shared/src/types.ts | 82 +++ 44 files changed, 2409 insertions(+), 67 deletions(-) create mode 100644 apps/api/src/modules/freight/handlers.rs create mode 100644 apps/api/src/modules/freight/mod.rs create mode 100644 apps/api/src/modules/freight/service.rs create mode 100644 apps/api/tests/freight.rs create mode 100644 apps/shop-admin/pages/freight-templates.vue rename openspec/changes/{add-freight-templates => archive/2026-09-24-add-freight-templates}/proposal.md (100%) rename openspec/changes/{add-freight-templates => archive/2026-09-24-add-freight-templates}/specs/frontend-mall/spec.md (100%) rename openspec/changes/{add-freight-templates => archive/2026-09-24-add-freight-templates}/specs/frontend-shop-admin/spec.md (100%) rename openspec/changes/{add-freight-templates => archive/2026-09-24-add-freight-templates}/specs/order/spec.md (100%) rename openspec/changes/{add-freight-templates => archive/2026-09-24-add-freight-templates}/specs/shipping/spec.md (100%) rename openspec/changes/{add-freight-templates => archive/2026-09-24-add-freight-templates}/tasks.md (81%) create mode 100644 openspec/specs/shipping/spec.md diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index 170998e..1f160e4 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -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, pub created_at: DateTime, pub updated_at: DateTime, } @@ -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, pub status: ShipmentStatus, pub shipped_at: Option>, pub delivered_at: Option>, diff --git a/apps/api/src/modules/freight/handlers.rs b/apps/api/src/modules/freight/handlers.rs new file mode 100644 index 0000000..425ff07 --- /dev/null +++ b/apps/api/src/modules/freight/handlers.rs @@ -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 { + 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) -> ApiResult>> { + Ok(Json(service::list_companies(&state).await?)) +} + +async fn list_templates( + State(state): State, + auth: AuthUser, +) -> ApiResult>> { + let shop_id = auth.require_shop()?; + Ok(Json(service::list(&state, shop_id).await?)) +} + +async fn create_template( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + let shop_id = auth.require_shop()?; + Ok(( + StatusCode::CREATED, + Json(service::create(&state, shop_id, body).await?), + )) +} + +async fn update_template( + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + let shop_id = auth.require_shop()?; + Ok(Json(service::update(&state, shop_id, id, body).await?)) +} + +async fn delete_template( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult { + let shop_id = auth.require_shop()?; + service::delete(&state, shop_id, id).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/apps/api/src/modules/freight/mod.rs b/apps/api/src/modules/freight/mod.rs new file mode 100644 index 0000000..755adf0 --- /dev/null +++ b/apps/api/src/modules/freight/mod.rs @@ -0,0 +1,4 @@ +pub mod handlers; +pub mod service; + +pub use handlers::router; diff --git a/apps/api/src/modules/freight/service.rs b/apps/api/src/modules/freight/service.rs new file mode 100644 index 0000000..70515f4 --- /dev/null +++ b/apps/api/src/modules/freight/service.rs @@ -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, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct RegionRuleRow { + pub id: Uuid, + pub template_id: Uuid, + pub regions: Vec, + 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, +} + +#[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, + 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, + pub always_free: Option, + 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, + pub region_rules: Option>, +} + +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, +) -> ApiResult> { + let ids: Vec = 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> { + 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 { + 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 { + 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 { + 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> { + 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 { + 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, + /// 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 { + 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 = 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 = lines.iter().map(|l| l.product_id).collect(); + let links: Vec<(Uuid, Option)> = 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 = 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> { + 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) +} diff --git a/apps/api/src/modules/fulfillment/service.rs b/apps/api/src/modules/fulfillment/service.rs index d12aae8..95205cc 100644 --- a/apps/api/src/modules/fulfillment/service.rs +++ b/apps/api/src/modules/fulfillment/service.rs @@ -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, pub items: Vec, } -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) -> ApiResult> { let ids: Vec = 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 { diff --git a/apps/api/src/modules/mod.rs b/apps/api/src/modules/mod.rs index c32c949..72a5de4 100644 --- a/apps/api/src/modules/mod.rs +++ b/apps/api/src/modules/mod.rs @@ -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 { .merge(favorite::router()) .merge(flash_sale::router()) .merge(group_buying::router()) + .merge(freight::router()) .merge(order::router()) .merge(points::router()) .merge(shop::router()) diff --git a/apps/api/src/modules/order/dto.rs b/apps/api/src/modules/order/dto.rs index 7b4683f..ffb61eb 100644 --- a/apps/api/src/modules/order/dto.rs +++ b/apps/api/src/modules/order/dto.rs @@ -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, + pub total_minor: i64, +} + #[derive(Clone, Copy)] pub enum OrderScope { User(uuid::Uuid), diff --git a/apps/api/src/modules/order/handlers.rs b/apps/api/src/modules/order/handlers.rs index 9a63aa7..f959901 100644 --- a/apps/api/src/modules/order/handlers.rs +++ b/apps/api/src/modules/order/handlers.rs @@ -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 { 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, + auth: AuthUser, + Json(body): Json, +) -> ApiResult> { + Ok(Json( + service::shipping_quote(&state, auth.id, body.shipping_address, body.currency).await?, + )) +} + #[derive(Deserialize)] struct CheckoutBody { shipping_address: AddressBody, diff --git a/apps/api/src/modules/order/repo.rs b/apps/api/src/modules/order/repo.rs index c33a107..5f2c2e9 100644 --- a/apps/api/src/modules/order/repo.rs +++ b/apps/api/src/modules/order/repo.rs @@ -146,6 +146,7 @@ pub async fn insert_order( currency: &str, total: i64, discount_minor: i64, + shipping_fee_minor: i64, coupon_id: Option, group_activity_id: Option, group_id: Option, @@ -153,9 +154,9 @@ pub async fn insert_order( ) -> ApiResult { 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, + freight_template_id: Option, + freight_pricing_method: Option, ) -> 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, pub price_minor: i64, pub currency: String, pub stock: i32, + pub weight_grams: Option, } pub async fn lock_purchasable_skus( @@ -352,8 +361,8 @@ pub async fn lock_purchasable_skus( sku_ids: &[Uuid], ) -> ApiResult> { 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> { + 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, diff --git a/apps/api/src/modules/order/service.rs b/apps/api/src/modules/order/service.rs index babde06..4eba4e5 100644 --- a/apps/api/src/modules/order/service.rs +++ b/apps/api/src/modules/order/service.rs @@ -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, + currency: String, + weight_grams: Option, normal_unit: i64, normal_qty: i32, activity_item: Option, @@ -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 = 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 = + 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 { + 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 = 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 = entries.iter().copied().collect(); + + let mut shop_ids: Vec = 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 = 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 { let mut tx = state.db.begin().await?; let order = repo::lock_for_user(&mut tx, user_id, id).await?; diff --git a/apps/api/src/modules/product/dto.rs b/apps/api/src/modules/product/dto.rs index 1d49e17..c3cbe10 100644 --- a/apps/api/src/modules/product/dto.rs +++ b/apps/api/src/modules/product/dto.rs @@ -55,6 +55,8 @@ pub struct ProductBody { pub category_id: Option, pub brand_id: Option, pub slug: String, + /// Freight template of the same shop; overrides the shop default. + pub freight_template_id: Option, pub name: serde_json::Value, pub description: Option, pub images: Option, diff --git a/apps/api/src/modules/product/service.rs b/apps/api/src/modules/product/service.rs index d76d38b..7795c7e 100644 --- a/apps/api/src/modules/product/service.rs +++ b/apps/api/src/modules/product/service.rs @@ -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 ApiResult { 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, +) -> 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 { 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 { 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) diff --git a/apps/api/tests/common/mod.rs b/apps/api/tests/common/mod.rs index 3e8b31a..cb14d98 100644 --- a/apps/api/tests/common/mod.rs +++ b/apps/api/tests/common/mod.rs @@ -16,6 +16,7 @@ impl TestApp { } pub async fn spawn_state() -> vmall_api::state::AppState { + let _ = tracing_subscriber::fmt().with_test_writer().try_init(); let config = Config { database_url: TEST_DB_URL.into(), redis_url: TEST_REDIS_URL.into(), diff --git a/apps/api/tests/freight.rs b/apps/api/tests/freight.rs new file mode 100644 index 0000000..ea84553 --- /dev/null +++ b/apps/api/tests/freight.rs @@ -0,0 +1,436 @@ +mod common; + +use common::{ + checkout, client, create_shop, login_admin, make_shop_owner, register_customer, setup_sellable, + spawn_app, TestApp, +}; +use serial_test::serial; + +/// Freight suite: templates, calculation boundaries, quote, and ship-company +/// dictionary. Each test builds its own shop/products and asserts only on them. + +async fn create_template( + app: &TestApp, + owner: &str, + body: serde_json::Value, +) -> reqwest::Response { + client() + .post(app.url("/api/shop/freight-templates")) + .bearer_auth(owner) + .json(&body) + .send() + .await + .unwrap() +} + +fn by_piece_body(name: &str, first_fee: i64, additional_fee: i64) -> serde_json::Value { + serde_json::json!({ + "name": name, + "pricing_method": "by_piece", + "first_fee_minor": first_fee, + "first_unit": 1, + "additional_fee_minor": additional_fee, + "additional_unit": 1, + }) +} + +#[tokio::test] +#[serial] +async fn template_crud_is_shop_scoped() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let shop_a = create_shop(&app, &admin, "ft-scope-a").await; + let shop_b = create_shop(&app, &admin, "ft-scope-b").await; + let owner_a = make_shop_owner(&app, &admin, &shop_a).await; + let owner_b = make_shop_owner(&app, &admin, &shop_b).await; + + let res = create_template(&app, &owner_a, by_piece_body("Standard", 500, 100)).await; + assert_eq!(res.status(), 201, "{:?}", res.text().await); + let created: serde_json::Value = res.json().await.unwrap(); + let id = created["id"].as_str().unwrap().to_string(); + assert_eq!(created["region_rules"].as_array().unwrap().len(), 0); + + // Owner A sees it; owner B cannot touch it. + let list: serde_json::Value = client() + .get(app.url("/api/shop/freight-templates")) + .bearer_auth(&owner_a) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(list.as_array().unwrap().iter().any(|t| t["id"] == id)); + + let res = client() + .put(app.url(&format!("/api/shop/freight-templates/{id}"))) + .bearer_auth(&owner_b) + .json(&by_piece_body("Hijack", 1, 1)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 404); + let res = client() + .delete(app.url(&format!("/api/shop/freight-templates/{id}"))) + .bearer_auth(&owner_b) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 404); + + // Delete works for the owner. + let res = client() + .delete(app.url(&format!("/api/shop/freight-templates/{id}"))) + .bearer_auth(&owner_a) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 204); +} + +#[tokio::test] +#[serial] +async fn only_one_default_template_per_shop() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let shop_id = create_shop(&app, &admin, "ft-default").await; + let owner = make_shop_owner(&app, &admin, &shop_id).await; + + let mut a = by_piece_body("A", 100, 100); + a["is_default"] = serde_json::json!(true); + assert_eq!(create_template(&app, &owner, a).await.status(), 201); + let mut b = by_piece_body("B", 200, 200); + b["is_default"] = serde_json::json!(true); + let res = create_template(&app, &owner, b).await; + assert_eq!(res.status(), 201, "{:?}", res.text().await); + + let list: serde_json::Value = client() + .get(app.url("/api/shop/freight-templates")) + .bearer_auth(&owner) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let defaults = list + .as_array() + .unwrap() + .iter() + .filter(|t| t["is_default"] == true) + .count(); + assert_eq!(defaults, 1, "exactly one default survives"); +} + +/// Buy `qty` of a SKU whose shop has `template` as default; returns the order. +async fn order_with_template( + app: &TestApp, + label: &str, + price_minor: i64, + qty: i32, + template: serde_json::Value, +) -> (String, serde_json::Value) { + let admin = login_admin(app).await; + let (owner, _shop, _product, sku_id) = setup_sellable(app, &admin, label, price_minor, 100).await; + let res = create_template(app, &owner, template).await; + assert_eq!(res.status(), 201, "{:?}", res.text().await); + let (customer, _) = register_customer(app, label).await; + common::add_to_cart(app, &customer, &sku_id, qty).await; + let orders = checkout(app, &customer).await; + (customer, orders.into_iter().next().unwrap()) +} + +#[tokio::test] +#[serial] +async fn by_piece_fee_merges_quantities_and_rounds_up() { + let app = spawn_app().await; + // first unit 1 @500, additional unit 2 pieces @300 + let mut t = by_piece_body("merged", 500, 300); + t["is_default"] = serde_json::json!(true); + t["additional_unit"] = serde_json::json!(2); + let (_customer, order) = order_with_template(&app, "ft-merge", 1000, 5, t).await; + + // 5 pieces: 500 + ceil(4/2)*300 = 1100 + assert_eq!(order["shipping_fee_minor"], 1100); + assert_eq!(order["total_minor"], 5 * 1000 + 1100); +} + +#[tokio::test] +#[serial] +async fn free_threshold_and_always_free_win() { + let app = spawn_app().await; + // Threshold 2000 met by 3x1000 -> fee 0. + let mut t = by_piece_body("threshold", 500, 300); + t["is_default"] = serde_json::json!(true); + t["free_threshold_minor"] = serde_json::json!(2000); + let (_c, order) = order_with_template(&app, "ft-free", 1000, 3, t).await; + assert_eq!(order["shipping_fee_minor"], 0); + assert_eq!(order["total_minor"], 3000); + + // Same template, subtotal below threshold -> fee applies. + let mut t2 = by_piece_body("threshold2", 500, 300); + t2["is_default"] = serde_json::json!(true); + t2["free_threshold_minor"] = serde_json::json!(5000); + let (_c2, order2) = order_with_template(&app, "ft-free2", 1000, 1, t2).await; + assert_eq!(order2["shipping_fee_minor"], 500); + + // always_free beats everything. + let mut t3 = by_piece_body("free", 999, 999); + t3["is_default"] = serde_json::json!(true); + t3["always_free"] = serde_json::json!(true); + let (_c3, order3) = order_with_template(&app, "ft-always", 1000, 4, t3).await; + assert_eq!(order3["shipping_fee_minor"], 0); +} + +#[tokio::test] +#[serial] +async fn region_rule_overrides_default_fees() { + let app = spawn_app().await; + let mut t = by_piece_body("regions", 500, 100); + t["is_default"] = serde_json::json!(true); + // The checkout fixture address has region "CA"; rule matches it. + t["region_rules"] = serde_json::json!([{ + "regions": ["CA"], + "first_fee_minor": 900, + "first_unit": 1, + "additional_fee_minor": 50, + "additional_unit": 1 + }]); + let (_c, order) = order_with_template(&app, "ft-region", 1000, 3, t).await; + // 900 + 2*50 = 1000 via the rule, not 500 + 2*100 = 700. + assert_eq!(order["shipping_fee_minor"], 1000); +} + +#[tokio::test] +#[serial] +async fn by_weight_merges_grams() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (owner, _shop, product_id, sku_id) = setup_sellable(&app, &admin, "ft-weight", 1000, 100).await; + // 250g per unit via SKU update. + sqlx::query("UPDATE skus SET weight_grams = 250 WHERE id = $1::uuid") + .bind(&sku_id) + .execute(&app.db) + .await + .unwrap(); + let _ = product_id; + + // first 500g @800, additional 500g @400 (partial additional rounds up). + let mut t = by_piece_body("weight", 800, 400); + t["pricing_method"] = serde_json::json!("by_weight"); + t["first_unit"] = serde_json::json!(500); + t["additional_unit"] = serde_json::json!(500); + t["is_default"] = serde_json::json!(true); + assert_eq!(create_template(&app, &owner, t).await.status(), 201); + + let (customer, _) = register_customer(&app, "ft-weight").await; + common::add_to_cart(&app, &customer, &sku_id, 5).await; + let orders = checkout(&app, &customer).await; + let order = &orders[0]; + // 5 * 250g = 1250g: 800 + ceil(750/500)*400 = 1600. + assert_eq!(order["shipping_fee_minor"], 1600); +} + +#[tokio::test] +#[serial] +async fn quote_matches_checkout_and_no_template_is_zero() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (owner, _shop, _product, sku_id) = setup_sellable(&app, &admin, "ft-quote", 1000, 100).await; + let mut t = by_piece_body("quoted", 500, 100); + t["is_default"] = serde_json::json!(true); + assert_eq!(create_template(&app, &owner, t).await.status(), 201); + + // Second shop without any template ships free. + let (_o2, _s2, _p2, sku2) = setup_sellable(&app, &admin, "ft-quote-b", 2000, 100).await; + + let (customer, _) = register_customer(&app, "ft-quote").await; + common::add_to_cart(&app, &customer, &sku_id, 3).await; + common::add_to_cart(&app, &customer, &sku2, 1).await; + + let res = client() + .post(app.url("/api/orders/shipping-quote")) + .bearer_auth(&customer) + .json(&serde_json::json!({ + "shipping_address": { + "recipient": "Q", "phone": "1", "country": "US", "region": "CA", + "city": "San Jose", "line1": "1 Way", "postal_code": "95131" + }, + "currency": "USD" + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + let quote: serde_json::Value = res.json().await.unwrap(); + // Templated shop: 500 + 2*100 = 700; template-less shop: 0. + assert_eq!(quote["total_minor"], 700); + let shops = quote["shops"].as_array().unwrap(); + assert_eq!(shops.len(), 2); + assert!(shops.iter().any(|s| s["fee_minor"] == 700)); + assert!(shops.iter().any(|s| s["fee_minor"] == 0)); + + let orders = checkout(&app, &customer).await; + let by_shop: std::collections::HashMap = orders + .iter() + .map(|o| { + ( + o["shop_id"].as_str().unwrap().to_string(), + o["shipping_fee_minor"].as_i64().unwrap(), + ) + }) + .collect(); + assert!(by_shop.values().any(|f| *f == 700)); + assert!(by_shop.values().any(|f| *f == 0)); +} + +#[tokio::test] +#[serial] +async fn product_template_link_and_cross_shop_rejection() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let shop_a = create_shop(&app, &admin, "ft-link-a").await; + let shop_b = create_shop(&app, &admin, "ft-link-b").await; + let owner_a = make_shop_owner(&app, &admin, &shop_a).await; + let owner_b = make_shop_owner(&app, &admin, &shop_b).await; + + let res = create_template(&app, &owner_b, by_piece_body("B tpl", 700, 100)).await; + let tpl_b = res.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + + // Shop A cannot point a product at shop B's template. + let res = client() + .post(app.url("/api/shop/products")) + .bearer_auth(&owner_a) + .json(&serde_json::json!({ + "slug": format!("ft-link-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]), + "name": {"en": "Linked", "zh": "关联"}, + "freight_template_id": tpl_b + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 400, "cross-shop template must be rejected"); + + // Own template links fine and wins over the shop default at checkout. + let res = create_template(&app, &owner_a, by_piece_body("A default", 100, 100)).await; + let mut default_tpl = res.json::().await.unwrap(); + default_tpl["is_default"] = serde_json::json!(true); + let default_id = default_tpl["id"].as_str().unwrap().to_string(); + assert_eq!( + client() + .put(app.url(&format!("/api/shop/freight-templates/{default_id}"))) + .bearer_auth(&owner_a) + .json(&serde_json::json!({ + "name": "A default", "is_default": true, "pricing_method": "by_piece", + "first_fee_minor": 100, "first_unit": 1, "additional_fee_minor": 100, + "additional_unit": 1 + })) + .send() + .await + .unwrap() + .status(), + 200 + ); + let res = create_template(&app, &owner_a, by_piece_body("A product tpl", 900, 900)).await; + let product_tpl = res.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + + let (product_id, slug) = + common::create_product_with_sku(&app, &owner_a, "ft-link-own", 1000, 50).await; + let res = client() + .put(app.url(&format!("/api/shop/products/{product_id}"))) + .bearer_auth(&owner_a) + .json(&serde_json::json!({ + "slug": slug, + "name": {"en": "Linked", "zh": "关联"}, + "freight_template_id": product_tpl + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + common::publish_product(&app, &owner_a, &product_id).await; + + let sku_id: String = sqlx::query_scalar("SELECT id::text FROM skus WHERE product_id = $1::uuid") + .bind(&product_id) + .fetch_one(&app.db) + .await + .unwrap(); + let (customer, _) = register_customer(&app, "ft-link-cust").await; + common::add_to_cart(&app, &customer, &sku_id, 1).await; + let orders = checkout(&app, &customer).await; + // Product template (900) wins over the shop default (100). + assert_eq!(orders[0]["shipping_fee_minor"], 900); + // The snapshot rides on the order item. + let detail: serde_json::Value = client() + .get(app.url(&format!("/api/orders/{}", orders[0]["id"].as_str().unwrap()))) + .bearer_auth(&customer) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(detail["items"][0]["freight_pricing_method"], "by_piece"); +} + +#[tokio::test] +#[serial] +async fn shipments_use_the_company_dictionary() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (owner, _shop, _product, sku_id) = setup_sellable(&app, &admin, "ft-ship", 1000, 10).await; + let (customer, _) = register_customer(&app, "ft-ship").await; + common::add_to_cart(&app, &customer, &sku_id, 1).await; + let orders = checkout(&app, &customer).await; + let order = &orders[0]; + common::pay(&app, &customer, order["id"].as_str().unwrap()).await; + let detail: serde_json::Value = client() + .get(app.url(&format!("/api/shop/orders/{}", order["id"].as_str().unwrap()))) + .bearer_auth(&owner) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let item_id = detail["items"][0]["id"].as_str().unwrap().to_string(); + + // Dictionary endpoint is public. + let res = client().get(app.url("/api/shipping/companies")).send().await.unwrap(); + assert_eq!(res.status(), 200); + let companies: serde_json::Value = res.json().await.unwrap(); + assert!(companies.as_array().unwrap().iter().any(|c| c["code"] == "sf-express")); + + // Unknown company is rejected. + let res = client() + .post(app.url(&format!("/api/shop/orders/{}/shipments", order["id"].as_str().unwrap()))) + .bearer_auth(&owner) + .json(&serde_json::json!({ + "carrier": "SF", "tracking_no": "SF123", "shipping_company_code": "nope", + "items": [{"order_item_id": item_id, "qty": 1}] + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 400); + + // Dictionary company is recorded. + let res = client() + .post(app.url(&format!("/api/shop/orders/{}/shipments", order["id"].as_str().unwrap()))) + .bearer_auth(&owner) + .json(&serde_json::json!({ + "carrier": "SF", "tracking_no": "SF123", "shipping_company_code": "sf-express", + "items": [{"order_item_id": item_id, "qty": 1}] + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201, "{:?}", res.text().await); + assert_eq!(res.json::().await.unwrap()["shipping_company_code"], "sf-express"); +} diff --git a/apps/mall/locales/checkout.ts b/apps/mall/locales/checkout.ts index f7dea0d..c7ff6af 100644 --- a/apps/mall/locales/checkout.ts +++ b/apps/mall/locales/checkout.ts @@ -26,6 +26,12 @@ export default { coupon: "Coupon", noCoupon: "No coupon", couponDiscount: "Coupon discount", + subtotal: "Goods subtotal", + shippingFee: "Shipping fee", + shippingTotal: "Shipping total", + freeShipping: "Free shipping", + selectAddressForShipping: "Select a shipping address to calculate the shipping fee.", + shippingQuoteError: "Unable to calculate the shipping fee. Please try again.", grandTotal: "Total payment", confirmPay: "Confirm payment", noPendingOrders: "There are no orders waiting for payment.", @@ -64,6 +70,12 @@ export default { coupon: "优惠券", noCoupon: "不使用优惠券", couponDiscount: "优惠券抵扣", + subtotal: "商品小计", + shippingFee: "运费", + shippingTotal: "运费合计", + freeShipping: "免运费", + selectAddressForShipping: "请先选择收货地址以计算运费。", + shippingQuoteError: "运费计算失败,请稍后重试。", grandTotal: "应付总额", confirmPay: "确认支付", noPendingOrders: "暂无待支付订单。", diff --git a/apps/mall/mock/api.ts b/apps/mall/mock/api.ts index 27d6ea9..cd82c6e 100644 --- a/apps/mall/mock/api.ts +++ b/apps/mall/mock/api.ts @@ -777,6 +777,8 @@ export function createMockApi(): ApiClient { } discount = Math.min(coupon.amount_minor, subtotal); } + // Same deterministic rule as quoteShipping: flat fee under the free threshold. + const shippingFee = subtotal - discount >= 20000 ? 0 : 599; const order: Order = { id: `o-${Date.now()}-${shopId}`, order_no: nextOrderNo(), @@ -784,8 +786,9 @@ export function createMockApi(): ApiClient { user_id: MOCK_USER.id, status: "pending_payment", currency, - total_minor: subtotal - discount, + total_minor: subtotal - discount + shippingFee, discount_minor: discount, + shipping_fee_minor: shippingFee, refund_total_minor: 0, coupon_id: couponId ?? null, group_activity_id: groupHere ? activity.id : null, @@ -879,6 +882,30 @@ export function createMockApi(): ApiClient { listMyShipments: () => Promise.resolve(state.shipments.map((s) => ({ ...s }))), + // Deterministic fixture rule: a flat per-shop fee, free at/over 200 major. + quoteShipping: (_address, currency) => { + const byShop = new Map(); + for (const item of state.cart) { + byShop.set(item.shop_id, (byShop.get(item.shop_id) ?? 0) + item.unit_price_minor * item.qty); + } + const shops = [...byShop.entries()].map(([shop_id, subtotal]) => ({ + shop_id, + fee_minor: subtotal >= 20000 ? 0 : 599, + })); + return Promise.resolve({ + currency, + shops, + total_minor: shops.reduce((sum, s) => sum + s.fee_minor, 0), + }); + }, + + listShippingCompanies: () => + Promise.resolve([ + { code: "sf-express", name: { en: "SF Express", zh: "顺丰速运" }, active: true }, + { code: "zto", name: { en: "ZTO Express", zh: "中通快递" }, active: true }, + { code: "ups", name: { en: "UPS", zh: "联合包裹" }, active: true }, + ]), + requestInvoice: (orderId: string, title: string, taxNo: string | null, kind: InvoiceKind) => { const order = state.orders.find((o) => o.id === orderId); if (!order) return Promise.reject(new ApiError(404, "NOT_FOUND", "Order not found")); @@ -1338,6 +1365,10 @@ export function createMockApi(): ApiClient { confirmAftersaleReceipt: (_id: string) => unsupported(), refundAftersale: (_id: string) => unsupported(), addAftersaleMessage: (_id: string, _body: AftersaleMessageBody) => unsupported(), + listFreightTemplates: () => unsupported(), + createFreightTemplate: () => unsupported(), + updateFreightTemplate: () => unsupported(), + deleteFreightTemplate: () => unsupported(), }, admin: { listUsers: () => unsupported(), diff --git a/apps/mall/mock/data.ts b/apps/mall/mock/data.ts index d92e7fc..b5a8baf 100644 --- a/apps/mall/mock/data.ts +++ b/apps/mall/mock/data.ts @@ -1146,9 +1146,10 @@ export function seedOrders(userId: string): MockOrderSeed { user_id: userId, status, currency: BASE_CURRENCY, - total_minor: total, + total_minor: total + 599, discount_minor: 0, refund_total_minor: 0, + shipping_fee_minor: 599, coupon_id: null, group_activity_id: null, group_id: null, diff --git a/apps/mall/pages/checkout/index.vue b/apps/mall/pages/checkout/index.vue index ed15ab0..bfe075a 100644 --- a/apps/mall/pages/checkout/index.vue +++ b/apps/mall/pages/checkout/index.vue @@ -7,6 +7,7 @@ import type { Coupon, GroupBuyIntent, LocalizedText, + ShippingQuote, } from "@vmall/shared"; type CheckoutGroup = { @@ -36,6 +37,11 @@ const remark = ref(""); const loading = ref(true); const submitting = ref(false); const error = ref(""); +/** Server-computed shipping fees for the current cart and destination. */ +const shippingQuote = ref(null); +const quoteFailed = ref(false); +let quoteTimer: ReturnType | undefined; +let quoteSeq = 0; // Inline manual form, used only when the customer has no saved address. const manual = reactive
({ @@ -75,6 +81,26 @@ const totalMinor = computed(() => items.value.reduce((total, item) => total + item.unit_price_minor * item.qty, 0), ); +/** Per-shop shipping fee from the latest server quote, keyed by shop id. */ +const shopFeeMinor = computed>(() => { + const fees: Record = {}; + for (const shop of shippingQuote.value?.shops ?? []) fees[shop.shop_id] = shop.fee_minor; + return fees; +}); +const shippingTotalMinor = computed(() => shippingQuote.value?.total_minor ?? 0); +/** Coupon preview only; the server decides the discount actually granted. */ +const couponDiscountMinor = computed(() => + groups.value.reduce((sum, group) => sum + (selectedCouponFor(group.shopId)?.amount_minor ?? 0), 0), +); +const grandTotalMinor = computed( + () => totalMinor.value + shippingTotalMinor.value - couponDiscountMinor.value, +); +const shippingHint = computed(() => { + if (quoteFailed.value) return t("checkout.shippingQuoteError"); + if (!selectedAddress()) return t("checkout.selectAddressForShipping"); + return t("common.loading"); +}); + function imageFor(item: CartItem): string { return item.image ?? "/mock/product-1.svg"; } @@ -111,6 +137,37 @@ function selectedAddress(): Address | null { return { ...manual }; } +/** Requote when the destination changes; without an address no fee is quoted. */ +async function refreshQuote(): Promise { + const address = selectedAddress(); + if (!address) { + shippingQuote.value = null; + quoteFailed.value = false; + return; + } + const seq = ++quoteSeq; + try { + const quote = await $api.quoteShipping(address, currency.value); + if (seq === quoteSeq) { + shippingQuote.value = quote; + quoteFailed.value = false; + } + } catch { + if (seq === quoteSeq) { + shippingQuote.value = null; + quoteFailed.value = true; + } + } +} + +// Saved-address switches and manual-form edits both reprice; the debounce +// keeps typing from firing a quote per keystroke. +const addressKey = computed(() => JSON.stringify(selectedAddress())); +watch(addressKey, () => { + if (quoteTimer) clearTimeout(quoteTimer); + quoteTimer = setTimeout(() => void refreshQuote(), 300); +}); + async function loadCart(): Promise { loading.value = true; error.value = ""; @@ -178,6 +235,7 @@ onMounted(() => void loadCart()); // consumed by a successful submit, which clears it before navigating to pay. onBeforeUnmount(() => { groupIntent.value = null; + if (quoteTimer) clearTimeout(quoteTimer); }); @@ -289,6 +347,20 @@ onBeforeUnmount(() => { :currency="item.currency" /> +
+ {{ t("checkout.shippingFee") }} + + {{ shippingHint }} +
{
- {{ t("checkout.total") }}: - +
+
+ {{ t("checkout.subtotal") }} + +
+
+ {{ t("checkout.shippingTotal") }} + + {{ shippingHint }} +
+
+ {{ t("checkout.couponDiscount") }} + − +
+
+ {{ t("checkout.total") }} + +
+
{{ t("checkout.submit") }} diff --git a/apps/mall/pages/checkout/pay.vue b/apps/mall/pages/checkout/pay.vue index f80b766..fe463d2 100644 --- a/apps/mall/pages/checkout/pay.vue +++ b/apps/mall/pages/checkout/pay.vue @@ -9,6 +9,8 @@ const { locale, t } = useI18n(); const { currency } = usePrefs(); const router = useRouter(); const pendingOrderIds = useState("checkout-orders", () => []); +/** Carried to the success page so it can show each paid order's money. */ +const paidOrders = useState("checkout-paid-orders", () => []); // Shared with the store directory; orders carry only a shop id. const { data: shops } = await useAsyncData("shops", () => $api.listShops(), { default: () => [] }); @@ -68,6 +70,7 @@ async function confirmPayment(): Promise { error.value = ""; try { for (const order of orders.value) await $api.payOrder(order.id); + paidOrders.value = orders.value; pendingOrderIds.value = []; await router.push("/checkout/success"); } catch { @@ -125,6 +128,12 @@ onMounted(() => void loadOrders()); />