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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<String, i64> = 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::<serde_json::Value>().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::<serde_json::Value>().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::<serde_json::Value>().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::<serde_json::Value>().await.unwrap()["shipping_company_code"], "sf-express");
|
||||
}
|
||||
@@ -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: "暂无待支付订单。",
|
||||
|
||||
+32
-1
@@ -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<string, number>();
|
||||
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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ShippingQuote | null>(null);
|
||||
const quoteFailed = ref(false);
|
||||
let quoteTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let quoteSeq = 0;
|
||||
|
||||
// Inline manual form, used only when the customer has no saved address.
|
||||
const manual = reactive<Address>({
|
||||
@@ -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<Record<string, number>>(() => {
|
||||
const fees: Record<string, number> = {};
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -289,6 +347,20 @@ onBeforeUnmount(() => {
|
||||
:currency="item.currency"
|
||||
/></strong>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3 pt-3 text-sm">
|
||||
<span class="text-muted">{{ t("checkout.shippingFee") }}</span>
|
||||
<template v-if="group.shopId in shopFeeMinor">
|
||||
<span v-if="shopFeeMinor[group.shopId] === 0" class="text-success">{{
|
||||
t("checkout.freeShipping")
|
||||
}}</span>
|
||||
<PriceText
|
||||
v-else
|
||||
:amount-minor="shopFeeMinor[group.shopId]"
|
||||
:currency="shippingQuote?.currency ?? sourceCurrency"
|
||||
/>
|
||||
</template>
|
||||
<span v-else class="text-muted">{{ shippingHint }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="!groupIntent && couponsForShop(group.shopId).length > 0"
|
||||
class="flex flex-wrap items-center gap-3 pt-3 text-sm"
|
||||
@@ -333,10 +405,38 @@ onBeforeUnmount(() => {
|
||||
<footer
|
||||
class="border-border bg-surface mb-4 flex flex-wrap items-center justify-end gap-4 rounded-lg border p-4 shadow-sm"
|
||||
>
|
||||
<span class="text-sm">{{ t("checkout.total") }}:</span>
|
||||
<strong class="text-primary text-lg"
|
||||
><PriceText :amount-minor="totalMinor" :currency="sourceCurrency"
|
||||
/></strong>
|
||||
<div class="ml-auto flex flex-col items-end gap-1.5 text-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-muted">{{ t("checkout.subtotal") }}</span>
|
||||
<PriceText :amount-minor="totalMinor" :currency="sourceCurrency" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-muted">{{ t("checkout.shippingTotal") }}</span>
|
||||
<template v-if="shippingQuote">
|
||||
<span v-if="shippingTotalMinor === 0" class="text-success">{{
|
||||
t("checkout.freeShipping")
|
||||
}}</span>
|
||||
<PriceText
|
||||
v-else
|
||||
:amount-minor="shippingTotalMinor"
|
||||
:currency="shippingQuote?.currency ?? sourceCurrency"
|
||||
/>
|
||||
</template>
|
||||
<span v-else class="text-muted">{{ shippingHint }}</span>
|
||||
</div>
|
||||
<div v-if="couponDiscountMinor > 0" class="flex items-center gap-3">
|
||||
<span class="text-muted">{{ t("checkout.couponDiscount") }}</span>
|
||||
<span class="text-danger"
|
||||
>−<PriceText :amount-minor="couponDiscountMinor" :currency="sourceCurrency"
|
||||
/></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span>{{ t("checkout.total") }}</span>
|
||||
<strong class="text-primary text-lg"
|
||||
><PriceText :amount-minor="grandTotalMinor" :currency="sourceCurrency"
|
||||
/></strong>
|
||||
</div>
|
||||
</div>
|
||||
<VBtn variant="primary" type="button" :disabled="submitting" @click="submitOrder">{{
|
||||
t("checkout.submit")
|
||||
}}</VBtn>
|
||||
|
||||
@@ -9,6 +9,8 @@ const { locale, t } = useI18n();
|
||||
const { currency } = usePrefs();
|
||||
const router = useRouter();
|
||||
const pendingOrderIds = useState<string[]>("checkout-orders", () => []);
|
||||
/** Carried to the success page so it can show each paid order's money. */
|
||||
const paidOrders = useState<Order[]>("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<void> {
|
||||
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());
|
||||
/></strong>
|
||||
</div>
|
||||
<footer class="flex flex-wrap items-center justify-end gap-3 px-4 py-3 text-sm">
|
||||
<span class="text-muted">{{ t("checkout.shippingFee") }}</span>
|
||||
<span v-if="order.shipping_fee_minor > 0"
|
||||
><PriceText
|
||||
:amount-minor="order.shipping_fee_minor"
|
||||
:currency="order.currency" /></span
|
||||
><span v-else>{{ t("checkout.freeShipping") }}</span>
|
||||
<template v-if="order.discount_minor > 0"
|
||||
><span>{{ t("checkout.couponDiscount") }}</span
|
||||
><span class="text-danger"
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { Order } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { t } = useI18n();
|
||||
/** Paid orders handed over by the pay page; empty on a fresh load. */
|
||||
const paidOrders = useState<Order[]>("checkout-paid-orders", () => []);
|
||||
|
||||
// One-shot display: revisiting the page must not show stale orders.
|
||||
onBeforeUnmount(() => {
|
||||
paidOrders.value = [];
|
||||
});
|
||||
|
||||
const stepLabels = computed(() => [
|
||||
t("checkout.steps.cart"),
|
||||
@@ -24,6 +33,31 @@ const stepLabels = computed(() => [
|
||||
</div>
|
||||
<h1 class="mt-5 mb-2 text-2xl font-medium">{{ t("checkout.successTitle") }}</h1>
|
||||
<p class="text-muted m-0">{{ t("checkout.successMessage") }}</p>
|
||||
<div v-if="paidOrders.length > 0" class="mx-auto mt-6 max-w-[560px] space-y-2 text-left">
|
||||
<div
|
||||
v-for="order in paidOrders"
|
||||
:key="order.id"
|
||||
class="border-border flex flex-wrap items-center justify-between gap-3 border p-3 text-sm"
|
||||
>
|
||||
<span class="text-muted">{{ t("checkout.orderNo") }} {{ order.order_no }}</span>
|
||||
<span class="flex items-center gap-4">
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="text-muted">{{ t("checkout.shippingFee") }}</span>
|
||||
<span v-if="order.shipping_fee_minor > 0"
|
||||
><PriceText
|
||||
:amount-minor="order.shipping_fee_minor"
|
||||
:currency="order.currency" /></span
|
||||
><span v-else>{{ t("checkout.freeShipping") }}</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="text-muted">{{ t("checkout.total") }}</span>
|
||||
<strong class="text-primary"
|
||||
><PriceText :amount-minor="order.total_minor" :currency="order.currency"
|
||||
/></strong>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-7 flex justify-center gap-3">
|
||||
<NuxtLink
|
||||
class="border-primary bg-primary hover:bg-primary-hover inline-flex items-center justify-center rounded-md border px-4 py-2 text-sm font-medium text-white"
|
||||
|
||||
@@ -180,6 +180,13 @@ onMounted(() => {
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
<p class="text-muted mt-4 text-right text-[13px]">
|
||||
{{ t("checkout.shippingFee") }}:
|
||||
<span v-if="order.shipping_fee_minor > 0">
|
||||
<PriceText :amount-minor="order.shipping_fee_minor" :currency="order.currency" />
|
||||
</span>
|
||||
<span v-else class="text-success">{{ t("checkout.freeShipping") }}</span>
|
||||
</p>
|
||||
<p v-if="order.discount_minor > 0" class="text-primary mt-4 text-right text-[13px]">
|
||||
{{ t("checkout.couponDiscount") }}:
|
||||
<PriceText :amount-minor="order.discount_minor" :currency="order.currency" />
|
||||
|
||||
@@ -56,6 +56,8 @@ const LIVE_PICKS = {
|
||||
getOrder: a.getOrder,
|
||||
cancelOrder: a.cancelOrder,
|
||||
payOrder: a.payOrder,
|
||||
quoteShipping: a.quoteShipping,
|
||||
listShippingCompanies: a.listShippingCompanies,
|
||||
}),
|
||||
shipments: (a: ApiClient) => ({
|
||||
confirmDelivered: a.confirmDelivered,
|
||||
|
||||
@@ -42,6 +42,12 @@ watchEffect(() => {
|
||||
class="text-muted hover:bg-bg rounded-md px-3 py-2 text-sm font-medium"
|
||||
>{{ $t("nav.shopProfile") }}</NuxtLink
|
||||
>
|
||||
<NuxtLink
|
||||
to="/freight-templates"
|
||||
active-class="bg-primary-soft text-primary"
|
||||
class="text-muted hover:bg-bg rounded-md px-3 py-2 text-sm font-medium"
|
||||
>{{ $t("nav.freightTemplates") }}</NuxtLink
|
||||
>
|
||||
<NuxtLink
|
||||
to="/orders"
|
||||
active-class="bg-primary-soft text-primary"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { t as localized } from "@vmall/shared";
|
||||
import type { Category, Product, ProductUpsertBody } from "@vmall/shared";
|
||||
import type { Category, FreightTemplate, Product, ProductUpsertBody } from "@vmall/shared";
|
||||
|
||||
const props = defineProps<{
|
||||
product?: Product;
|
||||
categories: Category[];
|
||||
freightTemplates: FreightTemplate[];
|
||||
busy?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{ submit: [body: ProductUpsertBody] }>();
|
||||
@@ -16,6 +17,7 @@ const nameZh = ref("");
|
||||
const descriptionEn = ref("");
|
||||
const descriptionZh = ref("");
|
||||
const categoryId = ref("");
|
||||
const freightTemplateId = ref("");
|
||||
const images = ref("");
|
||||
const validationError = ref("");
|
||||
|
||||
@@ -26,6 +28,7 @@ function resetFromProduct(product: Product | undefined): void {
|
||||
descriptionEn.value = product?.description.en ?? "";
|
||||
descriptionZh.value = product?.description.zh ?? "";
|
||||
categoryId.value = product?.category_id ?? "";
|
||||
freightTemplateId.value = product?.freight_template_id ?? "";
|
||||
images.value = product?.images.join("\n") ?? "";
|
||||
validationError.value = "";
|
||||
}
|
||||
@@ -45,6 +48,7 @@ function submit(): void {
|
||||
validationError.value = "";
|
||||
const body: ProductUpsertBody = {
|
||||
category_id: categoryId.value || null,
|
||||
freight_template_id: freightTemplateId.value || null,
|
||||
slug: cleanSlug,
|
||||
name: { en: nameEn.value.trim(), zh: nameZh.value.trim() },
|
||||
description: { en: descriptionEn.value.trim(), zh: descriptionZh.value.trim() },
|
||||
@@ -100,6 +104,18 @@ function submit(): void {
|
||||
</option>
|
||||
</select>
|
||||
</VField>
|
||||
<VField :label="$t('shop.freightTemplate')">
|
||||
<select
|
||||
id="product-freight-template"
|
||||
v-model="freightTemplateId"
|
||||
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 w-full rounded-md border px-3 py-2 text-sm focus:outline-2"
|
||||
>
|
||||
<option value="">{{ $t("shop.freightTemplateDefault") }}</option>
|
||||
<option v-for="template in freightTemplates" :key="template.id" :value="template.id">
|
||||
{{ template.name }}
|
||||
</option>
|
||||
</select>
|
||||
</VField>
|
||||
<VField :label="$t('product.images')">
|
||||
<textarea
|
||||
id="product-images"
|
||||
|
||||
@@ -16,10 +16,40 @@ export function useMoney() {
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(amountMinor: number, currency: string): string {
|
||||
const exponent = currencies.value.find((c) => c.code === currency)?.exponent ?? 2;
|
||||
return formatMoney(amountMinor, currency, exponent, locale.value);
|
||||
function exponentOf(currency: string): number {
|
||||
return currencies.value.find((c) => c.code === currency)?.exponent ?? 2;
|
||||
}
|
||||
|
||||
return { currencies, load, fmt };
|
||||
function fmt(amountMinor: number, currency: string): string {
|
||||
return formatMoney(amountMinor, currency, exponentOf(currency), locale.value);
|
||||
}
|
||||
|
||||
/** Currency for money fields without an explicit currency: the table's base. */
|
||||
const shopCurrency = computed(
|
||||
() => currencies.value.find((c) => c.is_base)?.code ?? currencies.value[0]?.code ?? "USD",
|
||||
);
|
||||
|
||||
/** Parse a major-unit decimal string into integer minor units; null when invalid. */
|
||||
function majorToMinor(value: string, currency: string): number | null {
|
||||
const clean = value.trim();
|
||||
if (!/^\d+(?:\.\d+)?$/.test(clean)) return null;
|
||||
const exponent = exponentOf(currency);
|
||||
const [whole, fraction = ""] = clean.split(".");
|
||||
if (fraction.length > exponent) return null;
|
||||
const minor = Number(whole) * 10 ** exponent + Number(fraction.padEnd(exponent, "0") || 0);
|
||||
return Number.isSafeInteger(minor) ? minor : null;
|
||||
}
|
||||
|
||||
/** Render integer minor units as a major-unit string for form inputs. */
|
||||
function minorToMajor(minor: number, currency: string): string {
|
||||
const exponent = exponentOf(currency);
|
||||
const sign = minor < 0 ? "-" : "";
|
||||
const abs = Math.abs(minor);
|
||||
const whole = Math.trunc(abs / 10 ** exponent);
|
||||
if (exponent === 0) return `${sign}${whole}`;
|
||||
const fraction = (abs % 10 ** exponent).toString().padStart(exponent, "0");
|
||||
return `${sign}${whole}.${fraction}`;
|
||||
}
|
||||
|
||||
return { currencies, load, fmt, shopCurrency, majorToMinor, minorToMajor };
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export const enExtra = {
|
||||
groupBuying: "Group buying",
|
||||
shopProfile: "Shop profile",
|
||||
aftersales: "After-sales",
|
||||
freightTemplates: "Freight templates",
|
||||
},
|
||||
shop: {
|
||||
profileSaved: "Shop profile saved.",
|
||||
@@ -56,7 +57,13 @@ export const enExtra = {
|
||||
skuPriceMajor: "Price (major units)",
|
||||
stock: "Stock",
|
||||
active: "Active",
|
||||
addSku: "Add SKU",
|
||||
skuSaved: "SKU saved.",
|
||||
skuWeight: "Weight (g)",
|
||||
skuWeightGrams: "Weight (grams, optional)",
|
||||
skuWeightInvalid: "Weight must be a positive integer.",
|
||||
freightTemplate: "Freight template",
|
||||
freightTemplateDefault: "Shop default",
|
||||
actions: "Actions",
|
||||
edit: "Edit",
|
||||
invoiceNo: "Invoice no.",
|
||||
@@ -71,6 +78,9 @@ export const enExtra = {
|
||||
phone: "Phone",
|
||||
carrier: "Carrier",
|
||||
trackingNo: "Tracking number",
|
||||
shippingCompany: "Shipping company",
|
||||
selectShippingCompany: "Select a shipping company",
|
||||
shippingCompanyRequired: "Choose a shipping company.",
|
||||
shipmentQuantities: "Shipment quantities",
|
||||
unshipped: "Unshipped",
|
||||
createShipment: "Create shipment",
|
||||
@@ -160,6 +170,45 @@ export const enExtra = {
|
||||
groupLifetimeInvalid: "Group lifetime must be a positive number of hours.",
|
||||
groupWindowInvalid: "End must not precede start.",
|
||||
},
|
||||
freight: {
|
||||
title: "Freight templates",
|
||||
newTemplate: "New template",
|
||||
editTemplate: "Edit template",
|
||||
name: "Template name",
|
||||
pricingMethod: "Pricing method",
|
||||
methodByPiece: "By piece",
|
||||
methodByWeight: "By weight",
|
||||
unitPiece: "pcs",
|
||||
unitGram: "g",
|
||||
firstFeeShort: "First fee",
|
||||
additionalFeeShort: "Additional fee",
|
||||
firstFeeMajor: "First fee (major units)",
|
||||
additionalFeeMajor: "Additional fee (major units)",
|
||||
firstUnit: "First unit",
|
||||
additionalUnit: "Additional unit",
|
||||
freeThresholdMajor: "Free shipping over (major units, optional)",
|
||||
alwaysFree: "Always free",
|
||||
isDefault: "Default template",
|
||||
defaultTag: "Default",
|
||||
createTemplate: "Create template",
|
||||
updateTemplate: "Update template",
|
||||
saved: "Freight template saved.",
|
||||
deleted: "Freight template deleted.",
|
||||
setDefault: "Set as default",
|
||||
defaultSaved: "Default template updated.",
|
||||
nameRequired: "Template name is required.",
|
||||
feeInvalid: "Fees must be non-negative amounts.",
|
||||
unitInvalid: "Unit sizes must be positive integers.",
|
||||
thresholdInvalid: "Free-shipping threshold must be a non-negative amount.",
|
||||
regionRules: "Region rules",
|
||||
noRegionRules: "No region rules yet.",
|
||||
addRule: "Add rule",
|
||||
ruleRegions: "Regions (comma separated)",
|
||||
ruleRegionsRequired: "Each rule needs at least one region.",
|
||||
deleteRule: "Delete rule",
|
||||
deleteRuleConfirm: "Delete this region rule?",
|
||||
deleteConfirm: "Delete this freight template?",
|
||||
},
|
||||
aftersale: {
|
||||
title: "After-sales",
|
||||
detail: "After-sale detail",
|
||||
@@ -222,6 +271,7 @@ export const zhExtra = {
|
||||
groupBuying: "拼团",
|
||||
shopProfile: "店铺资料",
|
||||
aftersales: "售后",
|
||||
freightTemplates: "运费模板",
|
||||
},
|
||||
shop: {
|
||||
profileSaved: "店铺资料已保存。",
|
||||
@@ -269,6 +319,11 @@ export const zhExtra = {
|
||||
stock: "库存",
|
||||
active: "启用",
|
||||
skuSaved: "SKU 已保存。",
|
||||
skuWeight: "重量(克)",
|
||||
skuWeightGrams: "重量(克,可选)",
|
||||
skuWeightInvalid: "重量必须为正整数。",
|
||||
freightTemplate: "运费模板",
|
||||
freightTemplateDefault: "店铺默认",
|
||||
actions: "操作",
|
||||
edit: "编辑",
|
||||
addSku: "添加 SKU",
|
||||
@@ -283,6 +338,9 @@ export const zhExtra = {
|
||||
phone: "电话",
|
||||
carrier: "承运商",
|
||||
trackingNo: "物流单号",
|
||||
shippingCompany: "物流公司",
|
||||
selectShippingCompany: "请选择物流公司",
|
||||
shippingCompanyRequired: "请选择物流公司。",
|
||||
shipmentQuantities: "发货数量",
|
||||
unshipped: "待发货",
|
||||
createShipment: "创建发货单",
|
||||
@@ -372,6 +430,45 @@ export const zhExtra = {
|
||||
groupLifetimeInvalid: "团有效期必须为正数小时。",
|
||||
groupWindowInvalid: "结束时间不能早于开始时间。",
|
||||
},
|
||||
freight: {
|
||||
title: "运费模板",
|
||||
newTemplate: "新建运费模板",
|
||||
editTemplate: "编辑运费模板",
|
||||
name: "模板名称",
|
||||
pricingMethod: "计价方式",
|
||||
methodByPiece: "按件",
|
||||
methodByWeight: "按重量",
|
||||
unitPiece: "件",
|
||||
unitGram: "克",
|
||||
firstFeeShort: "首费",
|
||||
additionalFeeShort: "续费",
|
||||
firstFeeMajor: "首费(主单位)",
|
||||
additionalFeeMajor: "续费(主单位)",
|
||||
firstUnit: "首段单位量",
|
||||
additionalUnit: "续段单位量",
|
||||
freeThresholdMajor: "满额免运费(主单位,可留空)",
|
||||
alwaysFree: "全程包邮",
|
||||
isDefault: "默认模板",
|
||||
defaultTag: "默认",
|
||||
createTemplate: "创建模板",
|
||||
updateTemplate: "更新模板",
|
||||
saved: "运费模板已保存。",
|
||||
deleted: "运费模板已删除。",
|
||||
setDefault: "设为默认",
|
||||
defaultSaved: "默认模板已更新。",
|
||||
nameRequired: "模板名称为必填项。",
|
||||
feeInvalid: "费用必须为非负金额。",
|
||||
unitInvalid: "单位量必须为正整数。",
|
||||
thresholdInvalid: "免运费门槛必须为非负金额。",
|
||||
regionRules: "地区规则",
|
||||
noRegionRules: "暂无地区规则。",
|
||||
addRule: "添加规则",
|
||||
ruleRegions: "地区(逗号分隔)",
|
||||
ruleRegionsRequired: "每条规则至少填写一个地区。",
|
||||
deleteRule: "删除规则",
|
||||
deleteRuleConfirm: "确定删除该地区规则?",
|
||||
deleteConfirm: "确定删除该运费模板?",
|
||||
},
|
||||
aftersale: {
|
||||
title: "售后",
|
||||
detail: "售后详情",
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
FreightRegionRule,
|
||||
FreightRegionRuleInput,
|
||||
FreightTemplate,
|
||||
FreightTemplateInput,
|
||||
} from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { t: translate } = useI18n();
|
||||
const { load: loadMoney, fmt, shopCurrency, majorToMinor, minorToMajor } = useMoney();
|
||||
|
||||
interface RuleForm {
|
||||
regions: string;
|
||||
firstFeeMajor: string;
|
||||
firstUnit: string;
|
||||
additionalFeeMajor: string;
|
||||
additionalUnit: string;
|
||||
}
|
||||
|
||||
const templates = ref<FreightTemplate[]>([]);
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const actionId = ref("");
|
||||
const error = ref("");
|
||||
const message = ref("");
|
||||
const editingId = ref("");
|
||||
|
||||
const form = reactive({
|
||||
name: "",
|
||||
pricingMethod: "by_piece" as FreightTemplate["pricing_method"],
|
||||
firstFeeMajor: "0",
|
||||
firstUnit: "1",
|
||||
additionalFeeMajor: "0",
|
||||
additionalUnit: "1",
|
||||
freeThresholdMajor: "",
|
||||
alwaysFree: false,
|
||||
isDefault: false,
|
||||
});
|
||||
const rules = ref<RuleForm[]>([]);
|
||||
|
||||
/** Unit word (pcs / g) for the given pricing method; many table + form call sites. */
|
||||
function unitOf(method: FreightTemplate["pricing_method"]): string {
|
||||
return method === "by_weight" ? translate("freight.unitGram") : translate("freight.unitPiece");
|
||||
}
|
||||
|
||||
function parseUnit(value: string): number | null {
|
||||
const clean = value.trim();
|
||||
return /^[1-9]\d*$/.test(clean) ? Number(clean) : null;
|
||||
}
|
||||
|
||||
function toRuleInput(rule: FreightRegionRule): FreightRegionRuleInput {
|
||||
return {
|
||||
regions: rule.regions,
|
||||
first_fee_minor: rule.first_fee_minor,
|
||||
first_unit: rule.first_unit,
|
||||
additional_fee_minor: rule.additional_fee_minor,
|
||||
additional_unit: rule.additional_unit,
|
||||
};
|
||||
}
|
||||
|
||||
/** Validated payload, or the first validation error to show. */
|
||||
function payload(): FreightTemplateInput | string {
|
||||
const name = form.name.trim();
|
||||
if (!name) return translate("freight.nameRequired");
|
||||
const firstFee = majorToMinor(form.firstFeeMajor, shopCurrency.value);
|
||||
const additionalFee = majorToMinor(form.additionalFeeMajor, shopCurrency.value);
|
||||
if (firstFee === null || additionalFee === null) return translate("freight.feeInvalid");
|
||||
const firstUnit = parseUnit(form.firstUnit);
|
||||
const additionalUnit = parseUnit(form.additionalUnit);
|
||||
if (firstUnit === null || additionalUnit === null) return translate("freight.unitInvalid");
|
||||
let threshold: number | null = null;
|
||||
if (form.freeThresholdMajor.trim()) {
|
||||
const parsed = majorToMinor(form.freeThresholdMajor, shopCurrency.value);
|
||||
if (parsed === null) return translate("freight.thresholdInvalid");
|
||||
threshold = parsed;
|
||||
}
|
||||
const regionRules: FreightRegionRuleInput[] = [];
|
||||
for (const rule of rules.value) {
|
||||
const regions = rule.regions
|
||||
.split(",")
|
||||
.map((region) => region.trim())
|
||||
.filter((region) => region.length > 0);
|
||||
if (!regions.length) return translate("freight.ruleRegionsRequired");
|
||||
const ruleFirstFee = majorToMinor(rule.firstFeeMajor, shopCurrency.value);
|
||||
const ruleAdditionalFee = majorToMinor(rule.additionalFeeMajor, shopCurrency.value);
|
||||
if (ruleFirstFee === null || ruleAdditionalFee === null) return translate("freight.feeInvalid");
|
||||
const ruleFirstUnit = parseUnit(rule.firstUnit);
|
||||
const ruleAdditionalUnit = parseUnit(rule.additionalUnit);
|
||||
if (ruleFirstUnit === null || ruleAdditionalUnit === null)
|
||||
return translate("freight.unitInvalid");
|
||||
regionRules.push({
|
||||
regions,
|
||||
first_fee_minor: ruleFirstFee,
|
||||
first_unit: ruleFirstUnit,
|
||||
additional_fee_minor: ruleAdditionalFee,
|
||||
additional_unit: ruleAdditionalUnit,
|
||||
});
|
||||
}
|
||||
return {
|
||||
name,
|
||||
is_default: form.isDefault,
|
||||
always_free: form.alwaysFree,
|
||||
pricing_method: form.pricingMethod,
|
||||
first_fee_minor: firstFee,
|
||||
first_unit: firstUnit,
|
||||
additional_fee_minor: additionalFee,
|
||||
additional_unit: additionalUnit,
|
||||
free_threshold_minor: threshold,
|
||||
region_rules: regionRules,
|
||||
};
|
||||
}
|
||||
|
||||
function resetForm(): void {
|
||||
editingId.value = "";
|
||||
form.name = "";
|
||||
form.pricingMethod = "by_piece";
|
||||
form.firstFeeMajor = "0";
|
||||
form.firstUnit = "1";
|
||||
form.additionalFeeMajor = "0";
|
||||
form.additionalUnit = "1";
|
||||
form.freeThresholdMajor = "";
|
||||
form.alwaysFree = false;
|
||||
form.isDefault = false;
|
||||
rules.value = [];
|
||||
error.value = "";
|
||||
}
|
||||
|
||||
function addRule(): void {
|
||||
rules.value.push({
|
||||
regions: "",
|
||||
firstFeeMajor: "0",
|
||||
firstUnit: "1",
|
||||
additionalFeeMajor: "0",
|
||||
additionalUnit: "1",
|
||||
});
|
||||
}
|
||||
|
||||
function removeRule(index: number): void {
|
||||
if (!confirm(translate("freight.deleteRuleConfirm"))) return;
|
||||
rules.value.splice(index, 1);
|
||||
}
|
||||
|
||||
function edit(template: FreightTemplate): void {
|
||||
editingId.value = template.id;
|
||||
form.name = template.name;
|
||||
form.pricingMethod = template.pricing_method;
|
||||
form.firstFeeMajor = minorToMajor(template.first_fee_minor, shopCurrency.value);
|
||||
form.firstUnit = String(template.first_unit);
|
||||
form.additionalFeeMajor = minorToMajor(template.additional_fee_minor, shopCurrency.value);
|
||||
form.additionalUnit = String(template.additional_unit);
|
||||
form.freeThresholdMajor =
|
||||
template.free_threshold_minor === null
|
||||
? ""
|
||||
: minorToMajor(template.free_threshold_minor, shopCurrency.value);
|
||||
form.alwaysFree = template.always_free;
|
||||
form.isDefault = template.is_default;
|
||||
rules.value = template.region_rules.map((rule) => ({
|
||||
regions: rule.regions.join(", "),
|
||||
firstFeeMajor: minorToMajor(rule.first_fee_minor, shopCurrency.value),
|
||||
firstUnit: String(rule.first_unit),
|
||||
additionalFeeMajor: minorToMajor(rule.additional_fee_minor, shopCurrency.value),
|
||||
additionalUnit: String(rule.additional_unit),
|
||||
}));
|
||||
error.value = "";
|
||||
message.value = "";
|
||||
}
|
||||
|
||||
async function loadTemplates(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
templates.value = await $api.shop.listFreightTemplates();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
error.value = "";
|
||||
message.value = "";
|
||||
const body = payload();
|
||||
if (typeof body === "string") {
|
||||
error.value = body;
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
if (editingId.value) await $api.shop.updateFreightTemplate(editingId.value, body);
|
||||
else await $api.shop.createFreightTemplate(body);
|
||||
message.value = translate("freight.saved");
|
||||
resetForm();
|
||||
await loadTemplates();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefault(template: FreightTemplate): Promise<void> {
|
||||
actionId.value = template.id;
|
||||
error.value = "";
|
||||
try {
|
||||
await $api.shop.updateFreightTemplate(template.id, {
|
||||
name: template.name,
|
||||
is_default: true,
|
||||
always_free: template.always_free,
|
||||
pricing_method: template.pricing_method,
|
||||
first_fee_minor: template.first_fee_minor,
|
||||
first_unit: template.first_unit,
|
||||
additional_fee_minor: template.additional_fee_minor,
|
||||
additional_unit: template.additional_unit,
|
||||
free_threshold_minor: template.free_threshold_minor,
|
||||
region_rules: template.region_rules.map(toRuleInput),
|
||||
});
|
||||
message.value = translate("freight.defaultSaved");
|
||||
await loadTemplates();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
actionId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(template: FreightTemplate): Promise<void> {
|
||||
if (!confirm(translate("freight.deleteConfirm"))) return;
|
||||
actionId.value = template.id;
|
||||
error.value = "";
|
||||
message.value = "";
|
||||
try {
|
||||
await $api.shop.deleteFreightTemplate(template.id);
|
||||
message.value = translate("freight.deleted");
|
||||
if (editingId.value === template.id) resetForm();
|
||||
await loadTemplates();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
actionId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadMoney();
|
||||
resetForm();
|
||||
await loadTemplates();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VPage :title="$t('freight.title')">
|
||||
<div v-if="error" class="text-danger my-2 text-sm" role="alert">{{ error }}</div>
|
||||
<div v-if="message" class="text-muted my-2 text-sm" role="status">{{ message }}</div>
|
||||
|
||||
<VCard class="mb-5">
|
||||
<h2 class="mb-4 text-base font-semibold">
|
||||
{{ editingId ? $t("freight.editTemplate") : $t("freight.newTemplate") }}
|
||||
</h2>
|
||||
<div class="my-3 grid [grid-template-columns:repeat(auto-fit,minmax(220px,1fr))] gap-3">
|
||||
<VField :label="$t('freight.name')">
|
||||
<VInput id="freight-name" v-model="form.name" type="text" />
|
||||
</VField>
|
||||
<VField :label="$t('freight.pricingMethod')">
|
||||
<select
|
||||
id="freight-pricing-method"
|
||||
v-model="form.pricingMethod"
|
||||
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 w-full rounded-md border px-3 py-2 text-sm focus:outline-2"
|
||||
>
|
||||
<option value="by_piece">{{ $t("freight.methodByPiece") }}</option>
|
||||
<option value="by_weight">{{ $t("freight.methodByWeight") }}</option>
|
||||
</select>
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.firstFeeMajor')} · ${shopCurrency}`">
|
||||
<VInput id="freight-first-fee" v-model="form.firstFeeMajor" inputmode="decimal" />
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.firstUnit')} (${unitOf(form.pricingMethod)})`">
|
||||
<VInput id="freight-first-unit" v-model="form.firstUnit" inputmode="numeric" />
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.additionalFeeMajor')} · ${shopCurrency}`">
|
||||
<VInput
|
||||
id="freight-additional-fee"
|
||||
v-model="form.additionalFeeMajor"
|
||||
inputmode="decimal"
|
||||
/>
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.additionalUnit')} (${unitOf(form.pricingMethod)})`">
|
||||
<VInput id="freight-additional-unit" v-model="form.additionalUnit" inputmode="numeric" />
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.freeThresholdMajor')} · ${shopCurrency}`">
|
||||
<VInput
|
||||
id="freight-free-threshold"
|
||||
v-model="form.freeThresholdMajor"
|
||||
inputmode="decimal"
|
||||
/>
|
||||
</VField>
|
||||
<div class="grid content-start gap-2">
|
||||
<label class="text-text flex items-center gap-2 text-sm font-medium"
|
||||
><input v-model="form.alwaysFree" type="checkbox" class="accent-primary h-4 w-4" />
|
||||
{{ $t("freight.alwaysFree") }}</label
|
||||
>
|
||||
<label class="text-text flex items-center gap-2 text-sm font-medium"
|
||||
><input v-model="form.isDefault" type="checkbox" class="accent-primary h-4 w-4" />
|
||||
{{ $t("freight.isDefault") }}</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-2 text-base font-semibold">{{ $t("freight.regionRules") }}</h3>
|
||||
<div v-if="!rules.length" class="text-muted mb-3 text-sm">
|
||||
{{ $t("freight.noRegionRules") }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(rule, index) in rules"
|
||||
:key="index"
|
||||
class="border-border bg-bg mb-3 rounded-md border p-3"
|
||||
>
|
||||
<div class="grid [grid-template-columns:repeat(auto-fit,minmax(200px,1fr))] gap-3">
|
||||
<VField :label="$t('freight.ruleRegions')">
|
||||
<VInput
|
||||
:id="`rule-regions-${index}`"
|
||||
v-model="rule.regions"
|
||||
type="text"
|
||||
:placeholder="$t('freight.ruleRegions')"
|
||||
/>
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.firstFeeMajor')} · ${shopCurrency}`">
|
||||
<VInput
|
||||
:id="`rule-first-fee-${index}`"
|
||||
v-model="rule.firstFeeMajor"
|
||||
inputmode="decimal"
|
||||
/>
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.firstUnit')} (${unitOf(form.pricingMethod)})`">
|
||||
<VInput :id="`rule-first-unit-${index}`" v-model="rule.firstUnit" inputmode="numeric" />
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.additionalFeeMajor')} · ${shopCurrency}`">
|
||||
<VInput
|
||||
:id="`rule-additional-fee-${index}`"
|
||||
v-model="rule.additionalFeeMajor"
|
||||
inputmode="decimal"
|
||||
/>
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.additionalUnit')} (${unitOf(form.pricingMethod)})`">
|
||||
<VInput
|
||||
:id="`rule-additional-unit-${index}`"
|
||||
v-model="rule.additionalUnit"
|
||||
inputmode="numeric"
|
||||
/>
|
||||
</VField>
|
||||
</div>
|
||||
<VBtn size="sm" variant="danger" @click="removeRule(index)">
|
||||
{{ $t("freight.deleteRule") }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<VBtn @click="addRule">{{ $t("freight.addRule") }}</VBtn>
|
||||
<VBtn variant="primary" :disabled="saving" @click="submit">
|
||||
{{ editingId ? $t("freight.updateTemplate") : $t("freight.createTemplate") }}
|
||||
</VBtn>
|
||||
<VBtn v-if="editingId" @click="resetForm">{{ $t("common.cancel") }}</VBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<p v-if="loading" class="text-muted">{{ $t("common.loading") }}</p>
|
||||
<VCard v-else-if="!templates.length" class="text-muted">{{ $t("common.empty") }}</VCard>
|
||||
<VTable v-else>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("freight.name") }}</th>
|
||||
<th>{{ $t("freight.pricingMethod") }}</th>
|
||||
<th>
|
||||
{{ $t("freight.firstFeeShort") }} / {{ $t("freight.additionalFeeShort") }}
|
||||
</th>
|
||||
<th>{{ $t("freight.freeThresholdMajor") }}</th>
|
||||
<th>{{ $t("freight.isDefault") }}</th>
|
||||
<th>{{ $t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="template in templates" :key="template.id">
|
||||
<td>
|
||||
<div class="font-medium">{{ template.name }}</div>
|
||||
<ul v-if="template.region_rules.length" class="text-muted mt-1 grid gap-0.5 text-xs">
|
||||
<li v-for="rule in template.region_rules" :key="rule.id">
|
||||
{{ rule.regions.join(", ") }}:
|
||||
{{ fmt(rule.first_fee_minor, shopCurrency) }}/{{ rule.first_unit }}
|
||||
{{ unitOf(template.pricing_method) }}
|
||||
+ {{ fmt(rule.additional_fee_minor, shopCurrency) }}/{{ rule.additional_unit }}
|
||||
{{ unitOf(template.pricing_method) }}
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td>
|
||||
{{
|
||||
template.pricing_method === "by_weight"
|
||||
? $t("freight.methodByWeight")
|
||||
: $t("freight.methodByPiece")
|
||||
}}
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
{{ fmt(template.first_fee_minor, shopCurrency) }} / {{ template.first_unit }}
|
||||
{{ unitOf(template.pricing_method) }}
|
||||
</div>
|
||||
<div>
|
||||
+ {{ fmt(template.additional_fee_minor, shopCurrency) }} /
|
||||
{{ template.additional_unit }} {{ unitOf(template.pricing_method) }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<VBadge v-if="template.always_free" tone="green">{{ $t("freight.alwaysFree") }}</VBadge>
|
||||
<span v-else-if="template.free_threshold_minor !== null">
|
||||
{{ fmt(template.free_threshold_minor, shopCurrency) }}
|
||||
</span>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td>
|
||||
<VBadge v-if="template.is_default" tone="blue">{{ $t("freight.defaultTag") }}</VBadge>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td class="flex flex-wrap gap-1.5">
|
||||
<VBtn size="sm" :disabled="actionId === template.id" @click="edit(template)">{{
|
||||
$t("shop.edit")
|
||||
}}</VBtn>
|
||||
<VBtn
|
||||
v-if="!template.is_default"
|
||||
size="sm"
|
||||
:disabled="actionId === template.id"
|
||||
@click="setDefault(template)"
|
||||
>{{ $t("freight.setDefault") }}</VBtn
|
||||
>
|
||||
<VBtn
|
||||
size="sm"
|
||||
variant="danger"
|
||||
:disabled="actionId === template.id"
|
||||
@click="remove(template)"
|
||||
>{{ $t("common.delete") }}</VBtn
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</VPage>
|
||||
</template>
|
||||
@@ -1,6 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { t as localized } from "@vmall/shared";
|
||||
import type { Order, OrderStatus, Shipment, ShipmentItemBody } from "@vmall/shared";
|
||||
import type {
|
||||
Order,
|
||||
OrderStatus,
|
||||
Shipment,
|
||||
ShipmentItemBody,
|
||||
ShippingCompany,
|
||||
} from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
@@ -11,9 +17,11 @@ const route = useRoute();
|
||||
const orderId = computed(() => String(route.params.id));
|
||||
const order = ref<Order | null>(null);
|
||||
const shipments = ref<Shipment[]>([]);
|
||||
const companies = ref<ShippingCompany[]>([]);
|
||||
const quantities = ref<Record<string, number>>({});
|
||||
const carrier = ref("");
|
||||
const trackingNo = ref("");
|
||||
const shippingCompanyCode = ref("");
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const actionId = ref("");
|
||||
@@ -34,10 +42,16 @@ function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
|
||||
}
|
||||
|
||||
function companyName(code: string | null | undefined): string {
|
||||
if (!code) return "—";
|
||||
const company = companies.value.find((item) => item.code === code);
|
||||
return company ? localized(company.name, locale.value) : code;
|
||||
}
|
||||
|
||||
function shippedQuantity(itemId: string): number {
|
||||
return shipments.value
|
||||
.filter((shipment) => shipment.order_id === orderId.value)
|
||||
.flatMap((shipment) => shipment.items)
|
||||
.flatMap((shipment) => shipment.items ?? [])
|
||||
.filter((item) => item.order_item_id === itemId)
|
||||
.reduce((total, item) => total + item.qty, 0);
|
||||
}
|
||||
@@ -63,12 +77,14 @@ async function loadOrder(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const [loadedOrder, loadedShipments] = await Promise.all([
|
||||
const [loadedOrder, loadedShipments, loadedCompanies] = await Promise.all([
|
||||
findShopOrder(),
|
||||
$api.shop.listShipments(),
|
||||
$api.listShippingCompanies(),
|
||||
]);
|
||||
order.value = loadedOrder;
|
||||
shipments.value = loadedShipments.filter((shipment) => shipment.order_id === orderId.value);
|
||||
companies.value = loadedCompanies;
|
||||
const nextQuantities: Record<string, number> = {};
|
||||
loadedOrder.items.forEach((item) => {
|
||||
nextQuantities[item.id] = Math.max(
|
||||
@@ -76,7 +92,7 @@ async function loadOrder(): Promise<void> {
|
||||
item.qty -
|
||||
loadedShipments
|
||||
.filter((shipment) => shipment.order_id === orderId.value)
|
||||
.flatMap((shipment) => shipment.items)
|
||||
.flatMap((shipment) => shipment.items ?? [])
|
||||
.filter((shipmentItem) => shipmentItem.order_item_id === item.id)
|
||||
.reduce((total, shipmentItem) => total + shipmentItem.qty, 0),
|
||||
);
|
||||
@@ -94,6 +110,10 @@ async function createShipment(): Promise<void> {
|
||||
error.value = translate("common.required");
|
||||
return;
|
||||
}
|
||||
if (!shippingCompanyCode.value) {
|
||||
error.value = translate("shop.shippingCompanyRequired");
|
||||
return;
|
||||
}
|
||||
const items: ShipmentItemBody[] = order.value.items
|
||||
.map((item) => ({ order_item_id: item.id, qty: quantities.value[item.id] ?? 0 }))
|
||||
.filter((item) => item.qty > 0);
|
||||
@@ -109,9 +129,11 @@ async function createShipment(): Promise<void> {
|
||||
carrier.value.trim(),
|
||||
trackingNo.value.trim(),
|
||||
items,
|
||||
shippingCompanyCode.value,
|
||||
);
|
||||
carrier.value = "";
|
||||
trackingNo.value = "";
|
||||
shippingCompanyCode.value = "";
|
||||
await loadOrder();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
@@ -208,6 +230,19 @@ onMounted(() => {
|
||||
<VField :label="$t('shop.trackingNo')">
|
||||
<VInput id="tracking-no" v-model="trackingNo" required />
|
||||
</VField>
|
||||
<VField :label="$t('shop.shippingCompany')">
|
||||
<select
|
||||
id="shipping-company"
|
||||
v-model="shippingCompanyCode"
|
||||
required
|
||||
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 w-full rounded-md border px-3 py-2 text-sm focus:outline-2"
|
||||
>
|
||||
<option value="" disabled>{{ $t("shop.selectShippingCompany") }}</option>
|
||||
<option v-for="company in companies" :key="company.code" :value="company.code">
|
||||
{{ localized(company.name, locale) }}
|
||||
</option>
|
||||
</select>
|
||||
</VField>
|
||||
</div>
|
||||
<h3 class="mb-3 text-base font-semibold">{{ $t("shop.shipmentQuantities") }}</h3>
|
||||
<div
|
||||
@@ -246,6 +281,7 @@ onMounted(() => {
|
||||
<tr>
|
||||
<th>{{ $t("shipment.shipmentNo") }}</th>
|
||||
<th>{{ $t("shop.carrier") }}</th>
|
||||
<th>{{ $t("shop.shippingCompany") }}</th>
|
||||
<th>{{ $t("shop.trackingNo") }}</th>
|
||||
<th>{{ $t("common.status") }}</th>
|
||||
<th>{{ $t("common.actions") }}</th>
|
||||
@@ -255,6 +291,7 @@ onMounted(() => {
|
||||
<tr v-for="shipment in shipments" :key="shipment.id">
|
||||
<td>{{ shipment.shipment_no }}</td>
|
||||
<td>{{ shipment.carrier }}</td>
|
||||
<td>{{ companyName(shipment.shipping_company_code) }}</td>
|
||||
<td>{{ shipment.tracking_no }}</td>
|
||||
<td>
|
||||
<VBadge :tone="shipmentStatusClass(shipment.status)">{{
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { Category, Product, ProductUpsertBody, Sku, SkuUpsertBody } from "@vmall/shared";
|
||||
import type {
|
||||
Category,
|
||||
FreightTemplate,
|
||||
Product,
|
||||
ProductUpsertBody,
|
||||
Sku,
|
||||
SkuUpsertBody,
|
||||
} from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
@@ -10,6 +17,7 @@ const route = useRoute();
|
||||
const productId = computed(() => String(route.params.id));
|
||||
const product = ref<Product | null>(null);
|
||||
const categories = ref<Category[]>([]);
|
||||
const freightTemplates = ref<FreightTemplate[]>([]);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const skuBusy = ref(false);
|
||||
@@ -18,6 +26,7 @@ const skuCode = ref("");
|
||||
const skuPrice = ref("");
|
||||
const skuCurrency = ref("USD");
|
||||
const skuStock = ref("0");
|
||||
const skuWeight = ref("");
|
||||
const skuActive = ref(true);
|
||||
|
||||
function formatDate(value: string): string {
|
||||
@@ -40,12 +49,14 @@ async function loadProduct(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const [loadedProduct, loadedCategories] = await Promise.all([
|
||||
const [loadedProduct, loadedCategories, loadedFreightTemplates] = await Promise.all([
|
||||
$api.shop.getProduct(productId.value),
|
||||
$api.listCategories(),
|
||||
$api.shop.listFreightTemplates(),
|
||||
]);
|
||||
product.value = loadedProduct;
|
||||
categories.value = loadedCategories;
|
||||
freightTemplates.value = loadedFreightTemplates;
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
@@ -69,10 +80,21 @@ async function updateProduct(body: ProductUpsertBody): Promise<void> {
|
||||
async function saveSku(): Promise<void> {
|
||||
const priceMinor = majorToMinor(skuPrice.value);
|
||||
const stock = Number(skuStock.value);
|
||||
if (!skuCode.value.trim() || priceMinor === null || !Number.isInteger(stock) || stock < 0) {
|
||||
const weightText = skuWeight.value.trim();
|
||||
const weight = weightText ? Number(weightText) : null;
|
||||
if (
|
||||
!skuCode.value.trim() ||
|
||||
priceMinor === null ||
|
||||
!Number.isInteger(stock) ||
|
||||
stock < 0
|
||||
) {
|
||||
error.value = translate("common.required");
|
||||
return;
|
||||
}
|
||||
if (weight !== null && (!Number.isInteger(weight) || weight <= 0)) {
|
||||
error.value = translate("shop.skuWeightInvalid");
|
||||
return;
|
||||
}
|
||||
skuBusy.value = true;
|
||||
error.value = "";
|
||||
const body: SkuUpsertBody = {
|
||||
@@ -81,6 +103,7 @@ async function saveSku(): Promise<void> {
|
||||
currency: skuCurrency.value,
|
||||
stock,
|
||||
active: skuActive.value,
|
||||
weight_grams: weight,
|
||||
};
|
||||
try {
|
||||
await $api.shop.upsertSku(productId.value, body);
|
||||
@@ -89,6 +112,7 @@ async function saveSku(): Promise<void> {
|
||||
skuPrice.value = "";
|
||||
skuCurrency.value = "USD";
|
||||
skuStock.value = "0";
|
||||
skuWeight.value = "";
|
||||
skuActive.value = true;
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
@@ -118,6 +142,7 @@ onMounted(() => {
|
||||
<ProductForm
|
||||
:product="product"
|
||||
:categories="categories"
|
||||
:freight-templates="freightTemplates"
|
||||
:busy="busy"
|
||||
@submit="updateProduct"
|
||||
/>
|
||||
@@ -131,6 +156,7 @@ onMounted(() => {
|
||||
<th>{{ $t("common.price") }}</th>
|
||||
<th>{{ $t("common.currency") }}</th>
|
||||
<th>{{ $t("shop.stock") }}</th>
|
||||
<th>{{ $t("shop.skuWeight") }}</th>
|
||||
<th>{{ $t("shop.active") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -140,6 +166,7 @@ onMounted(() => {
|
||||
<td>{{ formatSkuPrice(sku) }}</td>
|
||||
<td>{{ sku.currency }}</td>
|
||||
<td>{{ sku.stock }}</td>
|
||||
<td>{{ sku.weight_grams ?? "—" }}</td>
|
||||
<td>
|
||||
<VBadge :tone="sku.active ? 'green' : 'red'">{{
|
||||
sku.active ? $t("common.yes") : $t("common.no")
|
||||
@@ -171,6 +198,9 @@ onMounted(() => {
|
||||
<VField :label="$t('shop.stock')">
|
||||
<VInput id="sku-stock" v-model="skuStock" type="number" min="0" step="1" required />
|
||||
</VField>
|
||||
<VField :label="$t('shop.skuWeightGrams')">
|
||||
<VInput id="sku-weight" v-model="skuWeight" inputmode="numeric" />
|
||||
</VField>
|
||||
</div>
|
||||
<label class="text-text mb-3.5 flex items-center gap-2 text-sm font-medium"
|
||||
><input v-model="skuActive" type="checkbox" class="accent-primary h-4 w-4" />
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { Category, ProductUpsertBody } from "@vmall/shared";
|
||||
import type { Category, FreightTemplate, ProductUpsertBody } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { t: translate } = useI18n();
|
||||
const categories = ref<Category[]>([]);
|
||||
const freightTemplates = ref<FreightTemplate[]>([]);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function loadCategories(): Promise<void> {
|
||||
async function loadFormOptions(): Promise<void> {
|
||||
try {
|
||||
categories.value = await $api.listCategories();
|
||||
const [loadedCategories, loadedFreightTemplates] = await Promise.all([
|
||||
$api.listCategories(),
|
||||
$api.shop.listFreightTemplates(),
|
||||
]);
|
||||
categories.value = loadedCategories;
|
||||
freightTemplates.value = loadedFreightTemplates;
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
@@ -33,7 +39,7 @@ async function saveProduct(body: ProductUpsertBody): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadCategories);
|
||||
onMounted(loadFormOptions);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -47,6 +53,12 @@ onMounted(loadCategories);
|
||||
</template>
|
||||
<div v-if="error" class="text-danger my-2 text-sm" role="alert">{{ error }}</div>
|
||||
<p v-if="loading" class="text-muted">{{ $t("common.loading") }}</p>
|
||||
<ProductForm v-else :categories="categories" :busy="busy" @submit="saveProduct" />
|
||||
<ProductForm
|
||||
v-else
|
||||
:categories="categories"
|
||||
:freight-templates="freightTemplates"
|
||||
:busy="busy"
|
||||
@submit="saveProduct"
|
||||
/>
|
||||
</VPage>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { Shipment } from "@vmall/shared";
|
||||
import { t as localized } from "@vmall/shared";
|
||||
import type { Shipment, ShippingCompany } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale, t: translate } = useI18n();
|
||||
const shipments = ref<Shipment[]>([]);
|
||||
const companies = ref<ShippingCompany[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const actionId = ref("");
|
||||
|
||||
function companyName(code: string | null | undefined): string {
|
||||
if (!code) return "—";
|
||||
const company = companies.value.find((item) => item.code === code);
|
||||
return company ? localized(company.name, locale.value) : code;
|
||||
}
|
||||
|
||||
function statusClass(value: Shipment["status"]): "green" | "blue" | "orange" {
|
||||
return value === "delivered" ? "green" : value === "shipped" ? "blue" : "orange";
|
||||
}
|
||||
@@ -22,7 +30,12 @@ async function loadShipments(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
shipments.value = await $api.shop.listShipments();
|
||||
const [loadedShipments, loadedCompanies] = await Promise.all([
|
||||
$api.shop.listShipments(),
|
||||
$api.listShippingCompanies(),
|
||||
]);
|
||||
shipments.value = loadedShipments;
|
||||
companies.value = loadedCompanies;
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
@@ -57,6 +70,7 @@ onMounted(loadShipments);
|
||||
<th>{{ $t("shipment.shipmentNo") }}</th>
|
||||
<th>{{ $t("shop.orderNo") }}</th>
|
||||
<th>{{ $t("shop.carrier") }}</th>
|
||||
<th>{{ $t("shop.shippingCompany") }}</th>
|
||||
<th>{{ $t("shop.trackingNo") }}</th>
|
||||
<th>{{ $t("common.status") }}</th>
|
||||
<th>{{ $t("shop.created") }}</th>
|
||||
@@ -72,6 +86,7 @@ onMounted(loadShipments);
|
||||
}}</NuxtLink>
|
||||
</td>
|
||||
<td>{{ shipment.carrier }}</td>
|
||||
<td>{{ companyName(shipment.shipping_company_code) }}</td>
|
||||
<td>{{ shipment.tracking_no }}</td>
|
||||
<td>
|
||||
<VBadge :tone="statusClass(shipment.status)">{{
|
||||
|
||||
Reference in New Issue
Block a user