hardcode of dpd and packeta

This commit is contained in:
Priec
2026-06-17 17:27:19 +02:00
parent e8c0362a54
commit cd7a756a54
24 changed files with 694 additions and 152 deletions

35
src/integrations/dhl.rs Normal file
View 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
View 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
View 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
View 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
/// 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,
})
}