hardcode of dpd and packeta
This commit is contained in:
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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user