hardcode of dpd and packeta
This commit is contained in:
@@ -60,6 +60,7 @@ impl Hooks for App {
|
||||
Ok(vec![
|
||||
Box::new(initializers::view_engine::ViewEngineInitializer),
|
||||
Box::new(initializers::admin_seeder::AdminSeeder),
|
||||
Box::new(initializers::shipping_seeder::ShippingSeeder),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Admin order list, detail, and status updates.
|
||||
//! Admin order list, detail, status updates, and manual carrier dispatch.
|
||||
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
@@ -7,7 +7,8 @@ use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
models::{order_items, orders},
|
||||
integrations::{self, ShipmentRequest},
|
||||
models::{order_items, orders, shipping_methods},
|
||||
views::checkout as view,
|
||||
controllers::i18n::current_lang,
|
||||
shared::{guard, settings},
|
||||
@@ -15,6 +16,9 @@ use crate::{
|
||||
|
||||
pub(crate) const ORDER_STATUSES: [&str; 4] = ["pending", "paid", "shipped", "cancelled"];
|
||||
|
||||
/// Fallback parcel weight when products carry no weight of their own.
|
||||
const DEFAULT_PARCEL_WEIGHT_GRAMS: i32 = 1000;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StatusForm {
|
||||
status: String,
|
||||
@@ -40,15 +44,28 @@ async fn index(
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn show(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
/// Resolve the carrier code (`none`/`packeta`/`dpd`/`dhl`) for an order from its
|
||||
/// chosen shipping method, defaulting to `none` when unknown.
|
||||
async fn order_carrier(ctx: &AppContext, order: &orders::Model) -> Result<String> {
|
||||
let Some(code) = order.carrier_code.as_deref() else {
|
||||
return Ok("none".to_string());
|
||||
};
|
||||
Ok(shipping_methods::Entity::find()
|
||||
.filter(shipping_methods::Column::Code.eq(code))
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.map(|m| m.carrier)
|
||||
.unwrap_or_else(|| "none".to_string()))
|
||||
}
|
||||
|
||||
/// Render the order detail page, optionally with a dispatch error banner.
|
||||
async fn render_show(
|
||||
jar: &CookieJar,
|
||||
v: &TeraView,
|
||||
ctx: &AppContext,
|
||||
id: i32,
|
||||
error: Option<String>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let order = orders::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
@@ -58,22 +75,42 @@ async fn show(
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
let carrier = order_carrier(ctx, &order).await?;
|
||||
// The order can be sent only if it maps to a real carrier and hasn't been
|
||||
// dispatched yet.
|
||||
let can_ship = carrier != "none" && order.tracking_number.is_none();
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
v,
|
||||
"admin/orders/show.html",
|
||||
json!({
|
||||
"order": view::detail(
|
||||
&order,
|
||||
settings::get(&ctx, "bank_iban").unwrap_or(""),
|
||||
settings::get(&ctx, "bank_account_name").unwrap_or(""),
|
||||
settings::get(ctx, "bank_iban").unwrap_or(""),
|
||||
settings::get(ctx, "bank_account_name").unwrap_or(""),
|
||||
),
|
||||
"items": view::items(&items),
|
||||
"statuses": ORDER_STATUSES,
|
||||
"lang": current_lang(&jar),
|
||||
"carrier": carrier,
|
||||
"can_ship": can_ship,
|
||||
"ship_error": error,
|
||||
"lang": current_lang(jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn show(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
render_show(&jar, &v, &ctx, id, None).await
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn update_status(
|
||||
auth: auth::JWT,
|
||||
@@ -96,9 +133,82 @@ async fn update_status(
|
||||
format::redirect(&format!("/admin/orders/{id}"))
|
||||
}
|
||||
|
||||
/// Manually dispatch an order to its carrier. This is the *only* place that
|
||||
/// calls a carrier API, and it is triggered exclusively by an admin clicking
|
||||
/// "Send to carrier" after the goods are verified and ready.
|
||||
#[debug_handler]
|
||||
async fn ship(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let order = orders::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
|
||||
// Idempotency: never create a second shipment for an already-dispatched order.
|
||||
if order.tracking_number.is_some() {
|
||||
return render_show(
|
||||
&jar,
|
||||
&v,
|
||||
&ctx,
|
||||
id,
|
||||
Some("This order has already been sent to the carrier.".to_string()),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let carrier = order_carrier(&ctx, &order).await?;
|
||||
let goods_value = (order.total_cents - order.shipping_cents).max(0);
|
||||
let cod_cents = match order.payment_method.as_deref() {
|
||||
Some("cod") => order.total_cents,
|
||||
_ => 0,
|
||||
};
|
||||
let recipient = order
|
||||
.customer_name
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(&order.email);
|
||||
|
||||
let req = ShipmentRequest {
|
||||
order_number: &order.order_number,
|
||||
recipient_name: recipient,
|
||||
email: &order.email,
|
||||
address: order.address.as_deref(),
|
||||
city: order.city.as_deref(),
|
||||
zip: order.zip.as_deref(),
|
||||
country: order.country.as_deref(),
|
||||
pickup_point_id: order.pickup_point_id.as_deref(),
|
||||
cod_cents,
|
||||
currency: &order.currency,
|
||||
value_cents: goods_value,
|
||||
weight_grams: DEFAULT_PARCEL_WEIGHT_GRAMS,
|
||||
};
|
||||
|
||||
match integrations::create_shipment(&ctx, &carrier, req).await {
|
||||
Ok(result) => {
|
||||
let mut active = order.into_active_model();
|
||||
active.tracking_number = Set(Some(result.tracking_number));
|
||||
active.shipment_id = Set(Some(result.shipment_id));
|
||||
active.label_url = Set(result.label_url);
|
||||
active.status = Set("shipped".to_string());
|
||||
active.update(&ctx.db).await?;
|
||||
format::redirect(&format!("/admin/orders/{id}"))
|
||||
}
|
||||
// Show the carrier's error in-page rather than a generic error screen,
|
||||
// so the admin can fix the cause and retry.
|
||||
Err(err) => render_show(&jar, &v, &ctx, id, Some(err.to_string())).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/admin/orders", get(index))
|
||||
.add("/admin/orders/{id}", get(show))
|
||||
.add("/admin/orders/{id}/status", post(update_status))
|
||||
.add("/admin/orders/{id}/ship", post(ship))
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Admin management of shipping methods: add, edit (price + enabled), remove.
|
||||
//! Admin management of the built-in delivery options (Packeta, DPD).
|
||||
//!
|
||||
//! The options themselves are fixed and seeded by `initializers::shipping_seeder`
|
||||
//! — they cannot be added or removed here. The admin only sets each one's price
|
||||
//! and toggles whether it is offered at checkout.
|
||||
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter,
|
||||
QueryOrder, Set,
|
||||
};
|
||||
use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -15,7 +16,6 @@ use crate::{
|
||||
shared::{
|
||||
guard,
|
||||
money::{format_price, parse_price_to_cents},
|
||||
slug::{slugify, unique_slug},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -25,14 +25,6 @@ struct ShippingForm {
|
||||
enabled: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NewShippingForm {
|
||||
name: String,
|
||||
price: String,
|
||||
requires_pickup_point: Option<String>,
|
||||
enabled: Option<String>,
|
||||
}
|
||||
|
||||
fn is_checked(value: &Option<String>) -> bool {
|
||||
matches!(value.as_deref(), Some("on" | "true" | "1"))
|
||||
}
|
||||
@@ -57,6 +49,7 @@ async fn index(
|
||||
"code": m.code,
|
||||
"name": m.name,
|
||||
"price": format_price(m.price_cents),
|
||||
"carrier": m.carrier,
|
||||
"requires_pickup_point": m.requires_pickup_point,
|
||||
"enabled": m.enabled,
|
||||
})
|
||||
@@ -69,48 +62,6 @@ async fn index(
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn create(
|
||||
auth: auth::JWT,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(form): Form<NewShippingForm>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let name = form.name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(Error::BadRequest("name is required".to_string()));
|
||||
}
|
||||
// Stable unique `code` derived from the name; it's what checkout submits and
|
||||
// what an order stores, so it must not collide with an existing method.
|
||||
let code = unique_slug(&slugify(&name), |candidate| {
|
||||
let ctx = ctx.clone();
|
||||
async move {
|
||||
Ok(shipping_methods::Entity::find()
|
||||
.filter(shipping_methods::Column::Code.eq(candidate))
|
||||
.count(&ctx.db)
|
||||
.await?
|
||||
> 0)
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
// Append after existing methods.
|
||||
let position = shipping_methods::Entity::find().count(&ctx.db).await? as i32;
|
||||
|
||||
shipping_methods::ActiveModel {
|
||||
code: Set(code),
|
||||
name: Set(name),
|
||||
price_cents: Set(parse_price_to_cents(&form.price)?),
|
||||
requires_pickup_point: Set(is_checked(&form.requires_pickup_point)),
|
||||
enabled: Set(is_checked(&form.enabled)),
|
||||
position: Set(position),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
|
||||
format::redirect("/admin/shipping")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn update(
|
||||
auth: auth::JWT,
|
||||
@@ -130,25 +81,8 @@ async fn update(
|
||||
format::redirect("/admin/shipping")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn delete(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let method = shipping_methods::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
method.delete(&ctx.db).await?;
|
||||
format::redirect("/admin/shipping")
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/admin/shipping", get(index))
|
||||
.add("/admin/shipping", post(create))
|
||||
.add("/admin/shipping/{id}", post(update))
|
||||
.add("/admin/shipping/{id}/delete", post(delete))
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod admin_seeder;
|
||||
pub mod shipping_seeder;
|
||||
pub mod view_engine;
|
||||
|
||||
54
src/initializers/shipping_seeder.rs
Normal file
54
src/initializers/shipping_seeder.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
//! Ensures the built-in carrier delivery options (Packeta, DPD) always exist.
|
||||
//!
|
||||
//! These are the only delivery options the shop offers; the admin can price and
|
||||
//! enable/disable them but cannot add or remove options. We insert each one
|
||||
//! only when its `code` is missing, so an admin's price/enabled changes are
|
||||
//! never overwritten on the next boot.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||
|
||||
use crate::models::shipping_methods;
|
||||
|
||||
/// `(code, name, carrier, requires_pickup_point, default_price_cents, position)`
|
||||
const BUILTINS: [(&str, &str, &str, bool, i64, i32); 2] = [
|
||||
("packeta", "Packeta", "packeta", true, 290, 0),
|
||||
("dpd", "DPD", "dpd", false, 450, 1),
|
||||
];
|
||||
|
||||
pub struct ShippingSeeder;
|
||||
|
||||
#[async_trait]
|
||||
impl Initializer for ShippingSeeder {
|
||||
fn name(&self) -> String {
|
||||
"shipping-seeder".to_string()
|
||||
}
|
||||
|
||||
async fn before_run(&self, ctx: &AppContext) -> Result<()> {
|
||||
for (code, name, carrier, requires_pickup_point, price_cents, position) in BUILTINS {
|
||||
let exists = shipping_methods::Entity::find()
|
||||
.filter(shipping_methods::Column::Code.eq(code))
|
||||
.count(&ctx.db)
|
||||
.await?
|
||||
> 0;
|
||||
if exists {
|
||||
continue;
|
||||
}
|
||||
shipping_methods::ActiveModel {
|
||||
code: Set(code.to_string()),
|
||||
name: Set(name.to_string()),
|
||||
carrier: Set(carrier.to_string()),
|
||||
requires_pickup_point: Set(requires_pickup_point),
|
||||
price_cents: Set(price_cents),
|
||||
enabled: Set(true),
|
||||
position: Set(position),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
tracing::info!(carrier = code, "seeded built-in delivery option");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
35
src/integrations/dhl.rs
Normal file
35
src/integrations/dhl.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
//! DHL shipment creation. See `docs/integrations/dhl.md`.
|
||||
//!
|
||||
//! DHL has several APIs (Parcel DE Shipping, eCommerce, MyDHL Express) behind
|
||||
//! one developer portal; which one applies depends on your contract and the
|
||||
//! markets you ship to. As with DPD, the workflow is fully wired — only the
|
||||
//! authenticated HTTP call is left as a marked TODO so we don't ship an
|
||||
//! unverified payload.
|
||||
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
use super::{ShipmentRequest, ShipmentResult};
|
||||
use crate::shared::settings;
|
||||
|
||||
pub async fn create_shipment(ctx: &AppContext, _req: ShipmentRequest<'_>) -> Result<ShipmentResult> {
|
||||
let _base = settings::get(ctx, "dhl_api_base").filter(|s| !s.is_empty());
|
||||
let _key = settings::get(ctx, "dhl_api_key").filter(|s| !s.is_empty());
|
||||
let _secret = settings::get(ctx, "dhl_api_secret").filter(|s| !s.is_empty());
|
||||
let _account = settings::get(ctx, "dhl_account_number").filter(|s| !s.is_empty());
|
||||
|
||||
if _base.is_none() || _key.is_none() || _secret.is_none() || _account.is_none() {
|
||||
return Err(Error::BadRequest(
|
||||
"DHL is not configured: set settings.dhl_api_base / dhl_api_key / dhl_api_secret / dhl_account_number (see docs/integrations/dhl.md)".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// TODO(dhl): implement once the API subscription is known:
|
||||
// 1. OAuth2 client-credentials -> Bearer token (cache until expiry).
|
||||
// 2. POST the shipment: shipper = your account/EKP, consignee from
|
||||
// `_req`, product code (domestic/express), weight, references; add
|
||||
// customs data for non-EU destinations.
|
||||
// 3. Parse tracking number + label, return ShipmentResult.
|
||||
Err(Error::BadRequest(
|
||||
"DHL shipment API not finalised yet — fill in the request in src/integrations/dhl.rs per your DHL subscription (docs/integrations/dhl.md)".to_string(),
|
||||
))
|
||||
}
|
||||
38
src/integrations/dpd.rs
Normal file
38
src/integrations/dpd.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
//! DPD shipment creation. See `docs/integrations/dpd.md`.
|
||||
//!
|
||||
//! DPD's API (REST vs SOAP, base URL, exact field names) depends on your
|
||||
//! country and contract, so the request body below must be finalised against
|
||||
//! *your* DPD account before going live. The surrounding workflow — admin
|
||||
//! trigger, tracking storage, status update — is fully wired; only the HTTP
|
||||
//! call is left as a clearly-marked TODO so we don't ship an unverified payload
|
||||
//! that silently produces broken shipments.
|
||||
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
use super::{ShipmentRequest, ShipmentResult};
|
||||
use crate::shared::settings;
|
||||
|
||||
pub async fn create_shipment(ctx: &AppContext, _req: ShipmentRequest<'_>) -> Result<ShipmentResult> {
|
||||
// Required settings (add to config/*.yaml under `settings:` — see docs).
|
||||
let _base = settings::get(ctx, "dpd_api_base").filter(|s| !s.is_empty());
|
||||
let _login = settings::get(ctx, "dpd_login").filter(|s| !s.is_empty());
|
||||
let _password = settings::get(ctx, "dpd_password").filter(|s| !s.is_empty());
|
||||
let _customer = settings::get(ctx, "dpd_customer_number").filter(|s| !s.is_empty());
|
||||
|
||||
if _base.is_none() || _login.is_none() || _password.is_none() || _customer.is_none() {
|
||||
return Err(Error::BadRequest(
|
||||
"DPD is not configured: set settings.dpd_api_base / dpd_login / dpd_password / dpd_customer_number (see docs/integrations/dpd.md)".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// TODO(dpd): implement once the contract's API variant is known:
|
||||
// 1. POST login (delisId + password) -> auth token (cache it).
|
||||
// 2. POST storeOrders with the token: recipient from `_req` (or
|
||||
// parcelShopId = _req.pickup_point_id for DPD Pickup), weight,
|
||||
// cod = _req.cod_cents, reference = _req.order_number.
|
||||
// 3. Parse the returned parcel number (tracking) + label, then return:
|
||||
// Ok(ShipmentResult { shipment_id, tracking_number, label_url })
|
||||
Err(Error::BadRequest(
|
||||
"DPD shipment API not finalised yet — fill in the request in src/integrations/dpd.rs per your DPD contract (docs/integrations/dpd.md)".to_string(),
|
||||
))
|
||||
}
|
||||
65
src/integrations/mod.rs
Normal file
65
src/integrations/mod.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
//! Outbound carrier integrations for creating shipments.
|
||||
//!
|
||||
//! Shipments are **never created automatically**. An admin reviews an order and,
|
||||
//! once the goods are physically ready, explicitly triggers
|
||||
//! [`create_shipment`] from the order page. Only then does the eshop call the
|
||||
//! carrier's API. `orders::place` (checkout) does not touch any of this.
|
||||
//!
|
||||
//! Each delivery option (`shipping_methods.carrier`) maps to one carrier here.
|
||||
//! "none" means the option is fulfilled manually and has no API.
|
||||
|
||||
pub mod dhl;
|
||||
pub mod dpd;
|
||||
pub mod packeta;
|
||||
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
/// Everything a carrier needs to register a parcel, snapshotted from an order.
|
||||
pub struct ShipmentRequest<'a> {
|
||||
pub order_number: &'a str,
|
||||
pub recipient_name: &'a str,
|
||||
pub email: &'a str,
|
||||
pub address: Option<&'a str>,
|
||||
pub city: Option<&'a str>,
|
||||
pub zip: Option<&'a str>,
|
||||
pub country: Option<&'a str>,
|
||||
/// Carrier pickup-point / locker id, when the method requires one.
|
||||
pub pickup_point_id: Option<&'a str>,
|
||||
/// Cash-on-delivery amount in cents; `0` when payment is not COD.
|
||||
pub cod_cents: i64,
|
||||
pub currency: &'a str,
|
||||
/// Total order value in cents (for insurance / customs declarations).
|
||||
pub value_cents: i64,
|
||||
pub weight_grams: i32,
|
||||
}
|
||||
|
||||
/// What a carrier returns once the parcel is registered.
|
||||
pub struct ShipmentResult {
|
||||
/// Carrier-internal shipment/packet id.
|
||||
pub shipment_id: String,
|
||||
/// Public tracking number / barcode shown to the customer.
|
||||
pub tracking_number: String,
|
||||
/// Direct link to the shipping label PDF, if the carrier returns one.
|
||||
pub label_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Dispatch to the carrier named by `shipping_methods.carrier`. Returns an error
|
||||
/// for `"none"` (manual fulfilment) or an unknown carrier.
|
||||
pub async fn create_shipment(
|
||||
ctx: &AppContext,
|
||||
carrier: &str,
|
||||
req: ShipmentRequest<'_>,
|
||||
) -> Result<ShipmentResult> {
|
||||
match carrier {
|
||||
"packeta" => packeta::create_shipment(ctx, req).await,
|
||||
"dpd" => dpd::create_shipment(ctx, req).await,
|
||||
"dhl" => dhl::create_shipment(ctx, req).await,
|
||||
"none" | "" => Err(Error::BadRequest(
|
||||
"this delivery option is fulfilled manually (no carrier API)".to_string(),
|
||||
)),
|
||||
other => Err(Error::BadRequest(format!("unknown carrier '{other}'"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The carrier values offered in the admin UI. `none` is the manual default.
|
||||
pub const CARRIERS: [&str; 4] = ["none", "packeta", "dpd", "dhl"];
|
||||
114
src/integrations/packeta.rs
Normal file
114
src/integrations/packeta.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
//! Packeta (Zásilkovna) shipment creation via the REST `createPacket` call.
|
||||
//!
|
||||
//! See `docs/integrations/packeta.md`. Requires two settings:
|
||||
//! - `packeta_api_password` — the secret REST API password (NOT the widget key)
|
||||
//! - `packeta_sender_label` — your sender/eshop label configured in the portal
|
||||
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
use super::{ShipmentRequest, ShipmentResult};
|
||||
use crate::shared::settings;
|
||||
|
||||
const ENDPOINT: &str = "https://www.zasilkovna.cz/api/rest";
|
||||
|
||||
/// Minimal XML-entity escaping for values interpolated into the request body.
|
||||
fn xml_escape(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
/// Extract the text inside the first `<tag>…</tag>` pair, if present.
|
||||
fn extract(xml: &str, tag: &str) -> Option<String> {
|
||||
let open = format!("<{tag}>");
|
||||
let close = format!("</{tag}>");
|
||||
let start = xml.find(&open)? + open.len();
|
||||
let end = xml[start..].find(&close)? + start;
|
||||
Some(xml[start..end].trim().to_string())
|
||||
}
|
||||
|
||||
pub async fn create_shipment(ctx: &AppContext, req: ShipmentRequest<'_>) -> Result<ShipmentResult> {
|
||||
let api_password = settings::get(ctx, "packeta_api_password").filter(|s| !s.is_empty()).ok_or_else(|| {
|
||||
Error::BadRequest(
|
||||
"Packeta is not configured: set settings.packeta_api_password (see docs/integrations/packeta.md)".to_string(),
|
||||
)
|
||||
})?;
|
||||
let sender_label = settings::get(ctx, "packeta_sender_label").filter(|s| !s.is_empty()).ok_or_else(|| {
|
||||
Error::BadRequest(
|
||||
"Packeta is not configured: set settings.packeta_sender_label (see docs/integrations/packeta.md)".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// The scaffolded checkout flow delivers to a Packeta pickup point, so an
|
||||
// address id is required. (Home delivery uses a different routing flow.)
|
||||
let address_id = req.pickup_point_id.filter(|s| !s.is_empty()).ok_or_else(|| {
|
||||
Error::BadRequest("this order has no Packeta pickup point selected".to_string())
|
||||
})?;
|
||||
|
||||
let value = req.value_cents as f64 / 100.0;
|
||||
let cod = req.cod_cents as f64 / 100.0;
|
||||
let weight_kg = f64::from(req.weight_grams) / 1000.0;
|
||||
|
||||
let body = format!(
|
||||
"<createPacket>\
|
||||
<apiPassword>{}</apiPassword>\
|
||||
<packetAttributes>\
|
||||
<number>{}</number>\
|
||||
<name>{}</name>\
|
||||
<surname>-</surname>\
|
||||
<email>{}</email>\
|
||||
<addressId>{}</addressId>\
|
||||
<value>{:.2}</value>\
|
||||
<cod>{:.2}</cod>\
|
||||
<currency>{}</currency>\
|
||||
<weight>{:.3}</weight>\
|
||||
<eshop>{}</eshop>\
|
||||
</packetAttributes>\
|
||||
</createPacket>",
|
||||
xml_escape(api_password),
|
||||
xml_escape(req.order_number),
|
||||
xml_escape(req.recipient_name),
|
||||
xml_escape(req.email),
|
||||
xml_escape(address_id),
|
||||
value,
|
||||
cod,
|
||||
xml_escape(req.currency),
|
||||
weight_kg,
|
||||
xml_escape(sender_label),
|
||||
);
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.post(ENDPOINT)
|
||||
.header("Content-Type", "text/xml; charset=utf-8")
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::string(&format!("Packeta request failed: {e}")))?;
|
||||
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| Error::string(&format!("Packeta response read failed: {e}")))?;
|
||||
|
||||
// A successful response is <response><status>ok</status><result>…</result>.
|
||||
// A failure carries <status>fault</status> plus a <string>/<fault> message.
|
||||
if extract(&text, "status").as_deref() != Some("ok") {
|
||||
let message = extract(&text, "string")
|
||||
.or_else(|| extract(&text, "fault"))
|
||||
.unwrap_or_else(|| "unknown Packeta error".to_string());
|
||||
return Err(Error::BadRequest(format!("Packeta rejected the shipment: {message}")));
|
||||
}
|
||||
|
||||
let shipment_id = extract(&text, "id")
|
||||
.ok_or_else(|| Error::string("Packeta response missing packet id"))?;
|
||||
let tracking_number = extract(&text, "barcode").unwrap_or_else(|| shipment_id.clone());
|
||||
|
||||
Ok(ShipmentResult {
|
||||
label_url: Some(format!("https://tracking.packeta.com/sk/?id={tracking_number}")),
|
||||
shipment_id,
|
||||
tracking_number,
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod app;
|
||||
pub mod controllers;
|
||||
pub mod data;
|
||||
pub mod initializers;
|
||||
pub mod integrations;
|
||||
pub mod mailers;
|
||||
pub mod models;
|
||||
pub mod seed;
|
||||
|
||||
@@ -29,6 +29,9 @@ pub struct Model {
|
||||
pub shipping_cents: i64,
|
||||
pub pickup_point_id: Option<String>,
|
||||
pub pickup_point_name: Option<String>,
|
||||
pub tracking_number: Option<String>,
|
||||
pub shipment_id: Option<String>,
|
||||
pub label_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
||||
@@ -17,6 +17,7 @@ pub struct Model {
|
||||
pub requires_pickup_point: bool,
|
||||
pub enabled: bool,
|
||||
pub position: i32,
|
||||
pub carrier: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
||||
@@ -42,6 +42,9 @@ pub fn detail(order: &orders::Model, bank_iban: &str, bank_account_name: &str) -
|
||||
"payment_method": order.payment_method,
|
||||
"carrier_name": order.carrier_name,
|
||||
"pickup_point_name": order.pickup_point_name,
|
||||
"tracking_number": order.tracking_number,
|
||||
"shipment_id": order.shipment_id,
|
||||
"label_url": order.label_url,
|
||||
// Numeric, sequential order id doubles as the bank variable symbol.
|
||||
"variable_symbol": order.id,
|
||||
"bank_iban": bank_iban,
|
||||
|
||||
Reference in New Issue
Block a user