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

87
Cargo.lock generated
View File

@@ -1504,9 +1504,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1755,6 +1757,22 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots 1.0.7",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -2110,6 +2128,7 @@ dependencies = [
"loco-rs",
"migration",
"regex",
"reqwest",
"rstest",
"sea-orm",
"serde",
@@ -2332,6 +2351,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
@@ -3090,6 +3115,61 @@ dependencies = [
"serde",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.5.10",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.4",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.5.10",
"tracing",
"windows-sys 0.52.0",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -3297,16 +3377,21 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower 0.5.3",
"tower-http",
@@ -3316,6 +3401,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots 1.0.7",
]
[[package]]
@@ -3520,6 +3606,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]

View File

@@ -37,6 +37,8 @@ dotenvy = { version = "0.15" }
validator = { version = "0.20" }
uuid = { version = "1.6", features = ["v4"] }
include_dir = { version = "0.7" }
# outbound HTTP for carrier shipment APIs (Packeta / DPD / DHL)
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
# view engine i18n
fluent-templates = { version = "0.13", features = ["tera"] }
unic-langid = { version = "0.9" }

View File

@@ -261,8 +261,21 @@ bank-account-name = Account holder
bank-variable-symbol = Variable symbol
bank-amount = Amount
admin-shipping = Shipping
admin-shipping-desc = add, edit and remove delivery options.
admin-shipping-desc = set the price and availability of each delivery option.
shipping-enabled = Active
shipping-new = Add delivery option
shipping-add = Add
shipping-requires-pickup = Requires pickup point
shipping-carrier = Carrier
carrier-none = Manual (no API)
carrier-packeta = Packeta
carrier-dpd = DPD
carrier-dhl = DHL
order-fulfillment = Fulfillment
order-shipped-via = Sent via
order-tracking = Tracking
order-label = Print label
order-manual-fulfillment = Manual fulfilment — no carrier API for this option.
order-send-hint = When the goods are ready, send this order to the carrier.
order-send-to-carrier = Send to
order-send-confirm = Send this order to the carrier now?

View File

@@ -261,8 +261,21 @@ bank-account-name = Príjemca
bank-variable-symbol = Variabilný symbol
bank-amount = Suma
admin-shipping = Doprava
admin-shipping-desc = pridať, upraviť a odstrániť možnosti dopravy.
admin-shipping-desc = nastaviť cenu a dostupnosť jednotlivých možností dopravy.
shipping-enabled = Aktívne
shipping-new = Pridať možnosť dopravy
shipping-add = Pridať
shipping-requires-pickup = Vyžaduje výdajné miesto
shipping-carrier = Dopravca
carrier-none = Manuálne (bez API)
carrier-packeta = Packeta
carrier-dpd = DPD
carrier-dhl = DHL
order-fulfillment = Expedícia
order-shipped-via = Odoslané cez
order-tracking = Sledovanie
order-label = Tlačiť štítok
order-manual-fulfillment = Manuálne spracovanie — táto možnosť nemá API dopravcu.
order-send-hint = Keď je tovar pripravený, odošlite objednávku dopravcovi.
order-send-to-carrier = Odoslať dopravcovi
order-send-confirm = Odoslať túto objednávku dopravcovi teraz?

View File

@@ -9,6 +9,12 @@
<a href="/admin/orders" class="inline-flex items-center rounded-radius border border-outline px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="admin-orders", lang=lang | default(value='sk')) }}</a>
</div>
{% if ship_error %}
<div class="mt-4 rounded-radius border border-danger/40 bg-danger/10 px-4 py-3 text-sm font-medium text-danger">
{{ ship_error }}
</div>
{% endif %}
<div class="mt-6 grid gap-6 lg:grid-cols-3">
<div class="space-y-6 lg:col-span-2">
<div class="overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
@@ -69,6 +75,33 @@
{% endif %}
</div>
<div class="space-y-3 rounded-radius border border-outline bg-surface p-5 text-sm dark:border-outline-dark dark:bg-surface-dark-alt">
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="order-fulfillment", lang=lang | default(value='sk')) }}</p>
{% if order.tracking_number %}
<p class="text-on-surface/80 dark:text-on-surface-dark/80">
{{ t(key="order-shipped-via", lang=lang | default(value='sk')) }} <span class="font-medium">{{ carrier | upper }}</span>
</p>
<p class="text-on-surface/80 dark:text-on-surface-dark/80">
{{ t(key="order-tracking", lang=lang | default(value='sk')) }}: <span class="font-mono font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.tracking_number }}</span>
</p>
{% if order.label_url %}
<a href="{{ order.label_url }}" target="_blank" rel="noopener"
class="inline-flex items-center rounded-radius border border-outline px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="order-label", lang=lang | default(value='sk')) }}</a>
{% endif %}
{% elif carrier == "none" %}
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-manual-fulfillment", lang=lang | default(value='sk')) }}</p>
{% elif can_ship %}
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-send-hint", lang=lang | default(value='sk')) }}</p>
<form method="post" action="/admin/orders/{{ order.id }}/ship"
onsubmit="return confirm('{{ t(key="order-send-confirm", lang=lang | default(value='sk')) }}')">
<button type="submit"
class="inline-flex w-full items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
{{ t(key="order-send-to-carrier", lang=lang | default(value='sk')) }} {{ carrier | upper }}
</button>
</form>
{% endif %}
</div>
<form method="post" action="/admin/orders/{{ order.id }}/status" class="space-y-3 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
<label for="status" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="order-status", lang=lang | default(value='sk')) }}</label>
<select id="status" name="status"

View File

@@ -11,11 +11,11 @@
<div class="mt-6 space-y-4">
{% for method in methods %}
<div class="flex flex-wrap items-end gap-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
<form method="post" action="/admin/shipping/{{ method.id }}" class="flex flex-1 flex-wrap items-end gap-4">
<form method="post" action="/admin/shipping/{{ method.id }}"
class="flex flex-wrap items-end gap-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
<div class="min-w-40">
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ method.name }}</p>
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ method.code }}{% if method.requires_pickup_point %} · {{ t(key="checkout-pickup-point", lang=lang | default(value='sk')) }}{% endif %}</p>
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ method.carrier | upper }}{% if method.requires_pickup_point %} · {{ t(key="checkout-pickup-point", lang=lang | default(value='sk')) }}{% endif %}</p>
</div>
<div class="space-y-1.5">
<label for="price-{{ method.id }}" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
@@ -32,43 +32,6 @@
{{ t(key="save", lang=lang | default(value='sk')) }}
</button>
</form>
<form method="post" action="/admin/shipping/{{ method.id }}/delete"
onsubmit="return confirm('{{ t(key="confirm-delete", lang=lang | default(value='sk')) }}')">
<button type="submit"
class="inline-flex items-center justify-center rounded-radius border border-outline px-4 py-2 text-sm font-medium text-danger transition hover:bg-danger/10 dark:border-outline-dark">
{{ t(key="delete", lang=lang | default(value='sk')) }}
</button>
</form>
</div>
{% endfor %}
</div>
<form method="post" action="/admin/shipping"
class="mt-8 flex flex-wrap items-end gap-4 rounded-radius border border-dashed border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
<h2 class="w-full text-sm font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="shipping-new", lang=lang | default(value='sk')) }}</h2>
<div class="space-y-1.5">
<label for="new-name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="name", lang=lang | default(value='sk')) }}</label>
<input id="new-name" name="name" type="text" required
class="w-56 rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
</div>
<div class="space-y-1.5">
<label for="new-price" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
<input id="new-price" name="price" type="text" inputmode="decimal" value="0.00"
class="w-28 rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
</div>
<label class="flex items-center gap-2 pb-2">
<input type="checkbox" name="requires_pickup_point" value="on"
class="size-4 rounded border-outline text-primary focus:ring-primary dark:border-outline-dark">
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shipping-requires-pickup", lang=lang | default(value='sk')) }}</span>
</label>
<label class="flex items-center gap-2 pb-2">
<input type="checkbox" name="enabled" value="on" checked
class="size-4 rounded border-outline text-primary focus:ring-primary dark:border-outline-dark">
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shipping-enabled", lang=lang | default(value='sk')) }}</span>
</label>
<button type="submit"
class="ml-auto inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
{{ t(key="shipping-add", lang=lang | default(value='sk')) }}
</button>
</form>
{% endblock content %}

View File

@@ -108,6 +108,20 @@ settings:
# Packeta (Zásilkovna) web API key for the pickup-point picker widget.
# Empty falls back to a plain text field for the pickup point.
packeta_api_key: {{ get_env(name="PACKETA_API_KEY", default="") }}
# Packeta REST API secret + sender label, used by admin "Send to carrier"
# (manual shipment creation). See docs/integrations/packeta.md.
packeta_api_password: {{ get_env(name="PACKETA_API_PASSWORD", default="") }}
packeta_sender_label: {{ get_env(name="PACKETA_SENDER_LABEL", default="") }}
# DPD shipment API (see docs/integrations/dpd.md). Empty = not configured.
dpd_api_base: {{ get_env(name="DPD_API_BASE", default="") }}
dpd_login: {{ get_env(name="DPD_LOGIN", default="") }}
dpd_password: {{ get_env(name="DPD_PASSWORD", default="") }}
dpd_customer_number: {{ get_env(name="DPD_CUSTOMER_NUMBER", default="") }}
# DHL shipment API (see docs/integrations/dhl.md). Empty = not configured.
dhl_api_base: {{ get_env(name="DHL_API_BASE", default="") }}
dhl_api_key: {{ get_env(name="DHL_API_KEY", default="") }}
dhl_api_secret: {{ get_env(name="DHL_API_SECRET", default="") }}
dhl_account_number: {{ get_env(name="DHL_ACCOUNT_NUMBER", default="") }}
# Bank-transfer payment details shown on the order confirmation.
bank_iban: {{ get_env(name="BANK_IBAN", default="SK00 0000 0000 0000 0000 0000") }}
bank_account_name: {{ get_env(name="BANK_ACCOUNT_NAME", default="Kompress s.r.o.") }}

View File

@@ -35,12 +35,18 @@ home-delivery option that has no pickup point at all.
| Order stores carrier + pickup point | `orders` table (`carrier_code`, `carrier_name`, `pickup_point_id`, `pickup_point_name`, `shipping_cents`) | ✅ done |
| Settings lookup | `src/shared/settings.rs` → reads `settings.*` from `config/*.yaml` | ✅ done |
| Packeta pickup-point widget | `assets/views/shop/checkout.html` (loads when `packeta_api_key` set) | ✅ scaffolded |
| Shipment-creation API client (any carrier) | — | ❌ not built |
| Tracking number on order | — | ❌ not built |
| `shipping_methods.carrier` (which API a method maps to) | `migration/.../m20260617_000001_*` + admin add-form dropdown | ✅ done |
| Tracking / shipment id / label on order | `migration/.../m20260617_000002_*` (`orders.tracking_number`, `shipment_id`, `label_url`) | ✅ done |
| Manual "Send to carrier" admin action | `src/controllers/admin_orders.rs` (`ship`), order detail page | ✅ done |
| Carrier client dispatch | `src/integrations/` (`create_shipment`) | ✅ done |
| Packeta shipment client | `src/integrations/packeta.rs` (real `createPacket`) | ✅ done |
| DPD / DHL shipment clients | `src/integrations/dpd.rs`, `dhl.rs` | 🟡 credential-guarded stub — fill in HTTP call per contract |
So **pickup-point selection for Packeta is already wired** — it just needs an
API key. Everything else (DPD/DHL widgets, and *all* shipment-creation API
calls) is new work, described per carrier.
**Shipments are created only when an admin clicks "Send to carrier" on the order
page** — never automatically at checkout. Packeta is wired end-to-end (needs
just the API password + sender label). DPD/DHL run through the same flow but
their HTTP body must be finalised against your contract (clearly marked TODOs in
each file).
## Shared groundwork (do this once, before any carrier's API step)

View File

@@ -27,6 +27,8 @@ mod m20260616_132000_drop_blog_and_pages;
mod m20260616_150755_shipping_methods;
mod m20260616_150812_add_shipping_fields_to_orders;
mod m20260616_160000_add_parent_to_categories;
mod m20260617_000001_add_carrier_to_shipping_methods;
mod m20260617_000002_add_shipment_to_orders;
pub struct Migrator;
#[async_trait::async_trait]
@@ -58,6 +60,8 @@ impl MigratorTrait for Migrator {
Box::new(m20260616_150755_shipping_methods::Migration),
Box::new(m20260616_150812_add_shipping_fields_to_orders::Migration),
Box::new(m20260616_160000_add_parent_to_categories::Migration),
Box::new(m20260617_000001_add_carrier_to_shipping_methods::Migration),
Box::new(m20260617_000002_add_shipment_to_orders::Migration),
// inject-above (do not remove this comment)
]
}

View File

@@ -0,0 +1,24 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
// Which carrier API (if any) a delivery option maps to. "none" means the
// option is fulfilled manually and never calls an external API.
add_column(
m,
"shipping_methods",
"carrier",
ColType::StringWithDefault("none".to_string()),
)
.await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
remove_column(m, "shipping_methods", "carrier").await
}
}

View File

@@ -0,0 +1,23 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
// Populated only after an admin manually sends the order to a carrier.
add_column(m, "orders", "tracking_number", ColType::StringNull).await?;
add_column(m, "orders", "shipment_id", ColType::StringNull).await?;
add_column(m, "orders", "label_url", ColType::StringNull).await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
remove_column(m, "orders", "tracking_number").await?;
remove_column(m, "orders", "shipment_id").await?;
remove_column(m, "orders", "label_url").await?;
Ok(())
}
}

View File

@@ -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),
])
}

View File

@@ -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))
}

View File

@@ -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))
}

View File

@@ -1,2 +1,3 @@
pub mod admin_seeder;
pub mod shipping_seeder;
pub mod view_engine;

View 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
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,
})
}

View File

@@ -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;

View File

@@ -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)]

View File

@@ -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)]

View File

@@ -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,