shipping
This commit is contained in:
@@ -261,5 +261,8 @@ bank-account-name = Account holder
|
||||
bank-variable-symbol = Variable symbol
|
||||
bank-amount = Amount
|
||||
admin-shipping = Shipping
|
||||
admin-shipping-desc = set carrier prices and availability.
|
||||
admin-shipping-desc = add, edit and remove delivery options.
|
||||
shipping-enabled = Active
|
||||
shipping-new = Add delivery option
|
||||
shipping-add = Add
|
||||
shipping-requires-pickup = Requires pickup point
|
||||
|
||||
@@ -261,5 +261,8 @@ bank-account-name = Príjemca
|
||||
bank-variable-symbol = Variabilný symbol
|
||||
bank-amount = Suma
|
||||
admin-shipping = Doprava
|
||||
admin-shipping-desc = nastaviť cenu a dostupnosť dopravcov.
|
||||
admin-shipping-desc = pridať, upraviť a odstrániť možnosti dopravy.
|
||||
shipping-enabled = Aktívne
|
||||
shipping-new = Pridať možnosť dopravy
|
||||
shipping-add = Pridať
|
||||
shipping-requires-pickup = Vyžaduje výdajné miesto
|
||||
|
||||
@@ -11,27 +11,64 @@
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
{% for method in methods %}
|
||||
<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>
|
||||
</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>
|
||||
<input id="price-{{ method.id }}" name="price" type="text" inputmode="decimal" value="{{ method.price }}"
|
||||
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="enabled" value="on" {% if method.enabled %}checked{% endif %}
|
||||
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="save", lang=lang | default(value='sk')) }}
|
||||
</button>
|
||||
</form>
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
<input id="price-{{ method.id }}" name="price" type="text" inputmode="decimal" value="{{ method.price }}"
|
||||
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="enabled" value="on" {% if method.enabled %}checked{% endif %}
|
||||
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="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 %}
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
<a href="/shop/{{ product.slug }}"
|
||||
<div
|
||||
class="group flex flex-col overflow-hidden rounded-radius border border-outline bg-surface transition hover:border-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:hover:border-primary-dark">
|
||||
<div class="aspect-square overflow-hidden bg-surface-alt dark:bg-surface-dark">
|
||||
{% if product.image %}
|
||||
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition group-hover:scale-105">
|
||||
<a href="/shop/{{ product.slug }}" class="flex flex-1 flex-col">
|
||||
<div class="aspect-square overflow-hidden bg-surface-alt dark:bg-surface-dark">
|
||||
{% if product.image %}
|
||||
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition group-hover:scale-105">
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1 p-4 pb-2">
|
||||
<h3 class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
|
||||
<p class="mt-auto pt-2 font-semibold text-primary dark:text-primary-dark">{{ product.price }} {{ product.currency }}</p>
|
||||
</div>
|
||||
</a>
|
||||
<div class="flex flex-col gap-2 px-4 pb-4">
|
||||
{% if product.stock > 0 %}
|
||||
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}</p>
|
||||
<form method="post" action="/cart/add" hx-boost="false">
|
||||
<input type="hidden" name="product_id" value="{{ product.id }}">
|
||||
<input type="hidden" name="quantity" value="1">
|
||||
<button type="submit"
|
||||
class="inline-flex w-full items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
|
||||
{{ t(key="add-to-cart", lang=lang | default(value='sk')) }}
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="inline-flex justify-center rounded-radius bg-danger/10 px-3 py-2 text-xs font-medium text-danger">{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1 p-4">
|
||||
<h3 class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
|
||||
<p class="mt-auto pt-2 font-semibold text-primary dark:text-primary-dark">{{ product.price }} {{ product.currency }}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
120
docs/integrations/README.md
Normal file
120
docs/integrations/README.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Carrier integrations
|
||||
|
||||
This eshop manages **delivery options** as plain rows in the `shipping_methods`
|
||||
table (admin UI at `/admin/shipping` — add / edit price + toggle / remove). A
|
||||
delivery option is just a name, a price, and two flags. **None of that talks to
|
||||
a carrier yet** — it only decides what the customer can pick and how much they
|
||||
pay.
|
||||
|
||||
Integrating a real carrier (Packeta, DPD, DHL) means wiring two *separate*
|
||||
concerns on top of an existing delivery option:
|
||||
|
||||
1. **Pickup-point selection** (checkout, browser-side) — only for carriers that
|
||||
deliver to pickup points / lockers. The customer picks a point via the
|
||||
carrier's JS map widget; the chosen id + name land in the order.
|
||||
2. **Shipment creation** (server-side, after the order is placed) — you call the
|
||||
carrier's HTTP API to register the parcel, then store the returned tracking
|
||||
number and print the label.
|
||||
|
||||
These are independent: you can ship to a Packeta pickup point manually (no API)
|
||||
just by enabling the pickup widget, and you can create DHL labels via API for a
|
||||
home-delivery option that has no pickup point at all.
|
||||
|
||||
> ❗ This is **not** a many-to-many / database relationship between your tables.
|
||||
> A carrier is an **external HTTP API** you call from the server. The only
|
||||
> schema you add is a few columns (which carrier a method maps to; a tracking
|
||||
> number on the order) — see "Shared groundwork" below.
|
||||
|
||||
## What already exists in the codebase
|
||||
|
||||
| Piece | Where | Status |
|
||||
|---|---|---|
|
||||
| Delivery option CRUD | `src/controllers/admin_shipping.rs`, `assets/views/admin/shipping/index.html` | ✅ done |
|
||||
| `shipping_methods` table (`code`, `name`, `price_cents`, `requires_pickup_point`, `enabled`, `position`) | `migration/.../m20260616_150755_shipping_methods.rs` | ✅ done |
|
||||
| Carrier choice + pickup fields on checkout | `assets/views/shop/checkout.html` (`carrier_code`, `pickup_point_id`, `pickup_point_name`) | ✅ done |
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
|
||||
## Shared groundwork (do this once, before any carrier's API step)
|
||||
|
||||
The pickup-widget half needs nothing new. The **shipment-creation** half needs:
|
||||
|
||||
1. **An HTTP client dependency.** Add to `Cargo.toml`:
|
||||
```toml
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
```
|
||||
(Loco already pulls `tokio`/`serde`/`serde_json`.)
|
||||
|
||||
2. **A place for carrier clients.** Create `src/integrations/mod.rs` and a file
|
||||
per carrier (`packeta.rs`, `dpd.rs`, `dhl.rs`). Register `pub mod integrations;`
|
||||
in `src/lib.rs` (next to `pub mod controllers;` etc.).
|
||||
|
||||
3. **Map a delivery option to a carrier.** Add a `carrier` column to
|
||||
`shipping_methods` so each admin-created option knows which API (if any) to
|
||||
call. Generate the migration:
|
||||
```bash
|
||||
cargo loco generate migration add_carrier_to_shipping_methods carrier:string
|
||||
```
|
||||
Values: `none` (manual, the default), `packeta`, `dpd`, `dhl`. Then add a
|
||||
`<select name="carrier">` to the add-form in
|
||||
`assets/views/admin/shipping/index.html` and persist it in
|
||||
`admin_shipping::create`.
|
||||
|
||||
4. **Store the tracking number / label on the order.** Generate:
|
||||
```bash
|
||||
cargo loco generate migration add_tracking_to_orders \
|
||||
tracking_number:string shipment_id:string label_url:string
|
||||
```
|
||||
|
||||
5. **A "Create shipment" admin action.** In the admin order detail
|
||||
(`src/controllers/admin_orders.rs`), add a button/handler that: looks up the
|
||||
order's `carrier_code` → finds the `shipping_methods.carrier` → calls the
|
||||
matching `integrations::<carrier>::create_shipment(...)` → saves
|
||||
`tracking_number` + `label_url` back onto the order. Optionally do this
|
||||
automatically in `orders::place`, but a manual admin trigger is safer to
|
||||
start (you can review the order first).
|
||||
|
||||
After the groundwork, each carrier file implements one async function roughly
|
||||
like:
|
||||
|
||||
```rust
|
||||
pub struct ShipmentRequest<'a> {
|
||||
pub order_number: &'a str,
|
||||
pub recipient_name: &'a str,
|
||||
pub email: &'a str,
|
||||
pub phone: Option<&'a str>,
|
||||
pub address: Option<&'a str>,
|
||||
pub city: Option<&'a str>,
|
||||
pub zip: Option<&'a str>,
|
||||
pub country: Option<&'a str>,
|
||||
pub pickup_point_id: Option<&'a str>,
|
||||
pub cod_cents: i64, // 0 unless cash-on-delivery
|
||||
pub currency: &'a str,
|
||||
pub weight_grams: i32,
|
||||
}
|
||||
|
||||
pub struct ShipmentResult {
|
||||
pub shipment_id: String,
|
||||
pub tracking_number: String,
|
||||
pub label_url: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_shipment(ctx: &AppContext, req: ShipmentRequest<'_>) -> loco_rs::Result<ShipmentResult> { ... }
|
||||
```
|
||||
|
||||
## Read next
|
||||
|
||||
- [`packeta.md`](packeta.md) — Packeta / Zásilkovna (pickup points + home, SK/CZ-centric)
|
||||
- [`dpd.md`](dpd.md) — DPD (home delivery + Pickup parcelshops)
|
||||
- [`dhl.md`](dhl.md) — DHL (international, Parcel/Express)
|
||||
|
||||
> ⚠️ Carrier APIs change. Treat the endpoint names, field names, and auth
|
||||
> details here as a **map of the moving parts**, and confirm exact request
|
||||
> formats against each carrier's current developer portal before coding.
|
||||
150
docs/integrations/dhl.md
Normal file
150
docs/integrations/dhl.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# DHL integration
|
||||
|
||||
DHL is best for **home delivery and international/express** shipments. Like DPD,
|
||||
**nothing DHL-specific is scaffolded** here. DHL is mostly an **address (home)
|
||||
delivery** carrier — pickup points exist (DHL ServicePoint / Packstation, mostly
|
||||
DE) but most shops use DHL for door-to-door, so you can usually skip the pickup
|
||||
widget entirely.
|
||||
|
||||
> DHL has **several separate APIs** behind one developer portal
|
||||
> (<https://developer.dhl.com>). Pick the one that matches your service:
|
||||
> - **DHL Parcel DE (Post & Parcel Germany) — Shipping API** for German domestic
|
||||
> parcels / Packstation.
|
||||
> - **DHL eCommerce (Parcel) APIs** for various countries.
|
||||
> - **DHL Express — MyDHL API** for international express.
|
||||
> Confirm which your contract covers before coding.
|
||||
|
||||
---
|
||||
|
||||
## 1. Get DHL API access
|
||||
|
||||
1. Create an account on the **DHL Developer Portal**: <https://developer.dhl.com>.
|
||||
2. Create an **app** and subscribe it to the specific API you need (e.g.
|
||||
"Shipping API" or "MyDHL API"). You receive an **API key (client id) +
|
||||
secret**.
|
||||
3. Separately you need a **DHL business/customer account** (EKP / account
|
||||
number, billing number) — the developer key alone can't bill shipments. Link
|
||||
your business account credentials to the app.
|
||||
4. Most DHL APIs use **OAuth2 client-credentials**: you exchange key+secret for a
|
||||
short-lived **Bearer token**, then call the shipping endpoints with it. (Some
|
||||
older endpoints use Basic auth — check your API's docs.)
|
||||
|
||||
---
|
||||
|
||||
## 2. Create the delivery option
|
||||
|
||||
At **`/admin/shipping`** → "Add delivery option":
|
||||
- **Name**: e.g. `DHL` or `DHL Express (international)`
|
||||
- **Price**: your fee
|
||||
- **Requires pickup point**: ❌ off for normal home delivery
|
||||
(turn ✅ on *only* if you specifically offer DHL Packstation/ServicePoint and
|
||||
build a picker — see section 4)
|
||||
- ✅ **Active**
|
||||
|
||||
With the option active, customers can already choose DHL and you can create the
|
||||
label manually in DHL Business Customer Portal. The API (section 3) automates
|
||||
that.
|
||||
|
||||
---
|
||||
|
||||
## 3. Create shipments via the DHL API
|
||||
|
||||
Do the [shared groundwork](README.md#shared-groundwork-do-this-once-before-any-carriers-api-step)
|
||||
first. Set `shipping_methods.carrier = "dhl"` for your DHL options.
|
||||
|
||||
### Credentials
|
||||
|
||||
```bash
|
||||
DHL_API_KEY=your_client_id
|
||||
DHL_API_SECRET=your_client_secret
|
||||
DHL_ACCOUNT_NUMBER=your_ekp_or_billing_number
|
||||
DHL_API_BASE=https://api-eu.dhl.com # depends on the specific API
|
||||
```
|
||||
Add matching lines under `settings:` in `config/*.yaml`:
|
||||
```yaml
|
||||
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="") }}
|
||||
dhl_api_base: {{ get_env(name="DHL_API_BASE", default="") }}
|
||||
```
|
||||
|
||||
### Flow (OAuth2 + create shipment)
|
||||
|
||||
1. **Token** → `POST {base}/.../token` with `grant_type=client_credentials` +
|
||||
key/secret → `access_token` (Bearer; cache until it expires).
|
||||
2. **Create shipment** → `POST` the shipment-orders endpoint with the Bearer
|
||||
token: shipper (your account/EKP), consignee (recipient from the order),
|
||||
product code (domestic vs international/express), weight, customs data for
|
||||
non-EU, and references (`order_number`). COD is a value-added service if you
|
||||
offer it.
|
||||
3. **Label** → the response includes a **tracking/shipment number** and a
|
||||
**label** (PDF/base64). Store/print it.
|
||||
|
||||
### Client sketch (`src/integrations/dhl.rs`)
|
||||
|
||||
```rust
|
||||
use loco_rs::prelude::*;
|
||||
use crate::shared::settings;
|
||||
|
||||
async fn bearer(ctx: &AppContext) -> Result<String> {
|
||||
let base = settings::get(ctx, "dhl_api_base").unwrap_or_default();
|
||||
let key = settings::get(ctx, "dhl_api_key").unwrap_or_default();
|
||||
let secret = settings::get(ctx, "dhl_api_secret").unwrap_or_default();
|
||||
// POST client_credentials → access_token; cache with expiry.
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub async fn create_shipment(ctx: &AppContext, req: super::ShipmentRequest<'_>)
|
||||
-> Result<super::ShipmentResult>
|
||||
{
|
||||
let token = bearer(ctx).await?;
|
||||
let account = settings::get(ctx, "dhl_account_number").unwrap_or_default();
|
||||
// Build shipment JSON:
|
||||
// - shipper: your account address (account = EKP/billing number)
|
||||
// - consignee: req.recipient_name / address / city / zip / country
|
||||
// - details: weight, product code (domestic / express), currency
|
||||
// - refs: req.order_number
|
||||
// - for international: customs (HS codes, declared value, contents)
|
||||
// POST {base}/.../shipments with Authorization: Bearer {token}
|
||||
todo!("parse tracking number + label into ShipmentResult")
|
||||
}
|
||||
```
|
||||
|
||||
Wire into the admin "Create shipment" action for `carrier == "dhl"` orders.
|
||||
|
||||
> 🌍 **International note:** for shipments outside the EU customs union you must
|
||||
> send **customs/commodity data** (HS codes, declared value, item descriptions).
|
||||
> Your `order_items` only store name + price today — if you ship internationally
|
||||
> you'll likely add a customs description/HS-code field to products.
|
||||
|
||||
---
|
||||
|
||||
## 4. (Optional) DHL pickup points
|
||||
|
||||
If you offer **Packstation / ServicePoint**, set "Requires pickup point" ✅ on
|
||||
that delivery option and render DHL's **Location Finder** (a separate DHL API)
|
||||
in the checkout pickup block (the `x-show="requiresPoint"` section of
|
||||
`assets/views/shop/checkout.html`), writing the chosen locker id into the
|
||||
existing hidden `pickup_point_id` / `pickup_point_name` fields. For Packstation
|
||||
you also need the recipient's **DHL post number** — an extra field most shops
|
||||
avoid unless targeting Germany.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing
|
||||
|
||||
- DHL provides a **sandbox** environment per API (separate base URL + test
|
||||
credentials) on the developer portal. Get a token and create one test
|
||||
shipment there before production.
|
||||
- Validate the tracking number on <https://www.dhl.com/track>.
|
||||
|
||||
## 6. Go-live checklist
|
||||
|
||||
- [ ] DHL developer app created + subscribed to the right API
|
||||
- [ ] DHL business account (EKP/billing number) linked
|
||||
- [ ] `DHL_*` env vars set; matching `settings:` lines added to `config/production.yaml`
|
||||
- [ ] Delivery option created in `/admin/shipping`; `carrier = "dhl"` set
|
||||
- [ ] `src/integrations/dhl.rs` implemented; OAuth token caching working
|
||||
- [ ] (International) customs data available on products/items
|
||||
- [ ] Test shipment in DHL sandbox → tracking number stored on order
|
||||
- [ ] Switched from sandbox to production base URL/credentials
|
||||
147
docs/integrations/dpd.md
Normal file
147
docs/integrations/dpd.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# DPD integration
|
||||
|
||||
DPD offers **home/business delivery** and **DPD Pickup parcelshops & lockers**.
|
||||
Unlike Packeta, **nothing DPD-specific is scaffolded yet** in this repo, so this
|
||||
is a full integration: an optional pickup widget plus the shipment-creation API.
|
||||
|
||||
---
|
||||
|
||||
## 1. Get a DPD account & API access
|
||||
|
||||
1. You need a **business contract** with DPD in your country (e.g. DPD SK:
|
||||
<https://www.dpd.com/sk>). Ask your account manager for **API access**.
|
||||
2. DPD exposes a few different APIs depending on country/era — confirm which one
|
||||
your contract uses:
|
||||
- **REST Shipping API** (modern; JSON) — most new integrations.
|
||||
- **SOAP "Login/Shipment" web services** (older; still common in CEE).
|
||||
- Some markets use the **DPD Geodata / Shop Finder API** for parcelshops.
|
||||
3. You'll receive: an **API base URL**, a **delisId / login**, and a
|
||||
**password** (the SOAP `login` call returns a short-lived **auth token** you
|
||||
reuse on subsequent calls). REST variants use an API key/token directly.
|
||||
4. Note your **sender address** and **DPD customer number** — required on every
|
||||
shipment.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decide which DPD services you offer
|
||||
|
||||
Create one delivery option per service at **`/admin/shipping`** → "Add delivery
|
||||
option":
|
||||
|
||||
| Option | "Requires pickup point" | Notes |
|
||||
|---|---|---|
|
||||
| `DPD Home` (classic) | ❌ off | delivered to the address on the order |
|
||||
| `DPD Pickup` (parcelshop/locker) | ✅ on | customer must choose a shop/locker |
|
||||
|
||||
For `DPD Pickup` you need a **point picker** (section 3). For `DPD Home` you can
|
||||
skip straight to the API (section 4).
|
||||
|
||||
---
|
||||
|
||||
## 3. (Pickup only) Add the DPD parcelshop picker at checkout
|
||||
|
||||
The checkout already has the generic pickup machinery: when the selected method
|
||||
has `requires_pickup_point = true`, the block with hidden `pickup_point_id` /
|
||||
`pickup_point_name` shows (`assets/views/shop/checkout.html`, the `x-show=
|
||||
"requiresPoint"` block). Today that block only renders the **Packeta** widget
|
||||
(guarded by `{% if packeta_api_key %}`) or a text fallback.
|
||||
|
||||
To support DPD you make that block carrier-aware:
|
||||
|
||||
1. Pass a `dpd_enabled` / map-widget key flag into the checkout context from
|
||||
`src/controllers/checkout.rs` (like `packeta_api_key` is passed today).
|
||||
2. In the pickup block, branch on the chosen `carrier` (the Alpine `carrier`
|
||||
variable already holds the method `code`) and render DPD's parcelshop map
|
||||
widget when a DPD pickup method is selected. DPD provides an embeddable
|
||||
**map/widget** (or you query their **Shop Finder API** and render your own
|
||||
list); on selection, write the shop id into `pointId` and a human label into
|
||||
`pointName` — exactly what the existing hidden inputs expect.
|
||||
|
||||
No new order fields are needed — `pickup_point_id` / `pickup_point_name` already
|
||||
carry the DPD shop id + name.
|
||||
|
||||
---
|
||||
|
||||
## 4. Create shipments via the DPD API
|
||||
|
||||
Do the [shared groundwork](README.md#shared-groundwork-do-this-once-before-any-carriers-api-step)
|
||||
first. Set `shipping_methods.carrier = "dpd"` for your DPD options.
|
||||
|
||||
### Auth (SOAP-style, common in CEE)
|
||||
|
||||
```bash
|
||||
DPD_API_BASE=https://api.dpd.sk # from your account manager
|
||||
DPD_LOGIN=your_delis_login
|
||||
DPD_PASSWORD=your_password
|
||||
DPD_CUSTOMER_NUMBER=your_customer_no
|
||||
```
|
||||
Add matching lines under `settings:` in `config/*.yaml`:
|
||||
```yaml
|
||||
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="") }}
|
||||
```
|
||||
|
||||
### Flow
|
||||
|
||||
1. **Login** → `LoginService.getAuth(delisId, password)` returns an **auth
|
||||
token** (valid for a while; cache it).
|
||||
2. **Create shipment** → `ShipmentService.storeOrders(...)` with the auth token,
|
||||
recipient address (or parcelshop id for Pickup), parcel weight, references
|
||||
(your `order_number`), and COD amount if cash-on-delivery. Returns a
|
||||
**parcel number (MPS id)** = your tracking number, plus label data.
|
||||
3. **Label** → the same call (or `getParcelLabels`) returns a **PDF/ZPL label**;
|
||||
store or print it.
|
||||
|
||||
### Client sketch (`src/integrations/dpd.rs`)
|
||||
|
||||
```rust
|
||||
use loco_rs::prelude::*;
|
||||
use crate::shared::settings;
|
||||
|
||||
async fn auth_token(ctx: &AppContext) -> Result<String> {
|
||||
let base = settings::get(ctx, "dpd_api_base").unwrap_or_default();
|
||||
let login = settings::get(ctx, "dpd_login").unwrap_or_default();
|
||||
let pass = settings::get(ctx, "dpd_password").unwrap_or_default();
|
||||
// POST login → parse token from response. Cache it (e.g. in-memory w/ expiry).
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub async fn create_shipment(ctx: &AppContext, req: super::ShipmentRequest<'_>)
|
||||
-> Result<super::ShipmentResult>
|
||||
{
|
||||
let token = auth_token(ctx).await?;
|
||||
let customer = settings::get(ctx, "dpd_customer_number").unwrap_or_default();
|
||||
// Build storeOrders payload:
|
||||
// - product: "CL" (classic/home) or "Pickup" + parcelShopId = req.pickup_point_id
|
||||
// - recipient: req.recipient_name / address / city / zip / country / phone
|
||||
// - cod: req.cod_cents (set cash-on-delivery service if > 0)
|
||||
// - reference: req.order_number
|
||||
// POST to {base}/shipment ... with `token`.
|
||||
todo!("parse parcel number + label into ShipmentResult")
|
||||
}
|
||||
```
|
||||
|
||||
Wire it into the admin "Create shipment" action for `carrier == "dpd"` orders.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing
|
||||
|
||||
- DPD provides a **test/integration environment** (separate base URL +
|
||||
credentials) — get it from your account manager. Validate login + one
|
||||
shipment there first.
|
||||
- Confirm the returned parcel number tracks on
|
||||
`https://tracking.dpd.de/...` / your local DPD tracking site.
|
||||
|
||||
## 6. Go-live checklist
|
||||
|
||||
- [ ] DPD business contract + API credentials obtained
|
||||
- [ ] `DPD_*` env vars set; matching `settings:` lines added to `config/production.yaml`
|
||||
- [ ] Delivery option(s) created in `/admin/shipping` (`DPD Home` and/or `DPD Pickup`)
|
||||
- [ ] `carrier = "dpd"` set on those methods (via the shared `carrier` column)
|
||||
- [ ] (Pickup) parcelshop picker rendered in checkout for DPD methods
|
||||
- [ ] `src/integrations/dpd.rs` implemented; login token caching working
|
||||
- [ ] Test shipment in DPD test env → tracking number stored on order
|
||||
- [ ] Switched base URL/credentials from test to production
|
||||
174
docs/integrations/packeta.md
Normal file
174
docs/integrations/packeta.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Packeta (Zásilkovna) integration
|
||||
|
||||
Packeta delivers mainly to **pickup points** and **Z-BOX lockers** (plus
|
||||
home delivery in some regions). It's the most common choice for SK/CZ eshops.
|
||||
This repo is already **scaffolded** for Packeta's pickup-point picker — you
|
||||
mostly need an API key to switch it on. Shipment creation via API is extra,
|
||||
optional work.
|
||||
|
||||
---
|
||||
|
||||
## 1. Get a Packeta account & keys
|
||||
|
||||
1. Register a client account at <https://client.packeta.com> (Zásilkovna /
|
||||
Packeta). For SK: <https://www.packeta.sk>.
|
||||
2. In the client portal open **Client support → API / Nastavenia API** (or
|
||||
"Integrations"). You get **two different secrets** — don't mix them up:
|
||||
- **Web/Widget API key** — public-ish key used by the browser pickup-point
|
||||
widget (`Packeta.Widget.pick`). This is the one this repo already uses.
|
||||
- **API password (REST/SOAP)** — secret server key used to *create packets*
|
||||
(shipments). Never expose this to the browser.
|
||||
3. (For real shipping) configure your **sender/pickup address and label
|
||||
format** in the portal.
|
||||
|
||||
---
|
||||
|
||||
## 2. Activate the pickup-point picker (already built)
|
||||
|
||||
The checkout template already loads the widget and wires the chosen point into
|
||||
the order **whenever `packeta_api_key` is non-empty**
|
||||
(`assets/views/shop/checkout.html`):
|
||||
|
||||
- loads `https://widget.packeta.com/v6/www/js/library.js`
|
||||
- `Packeta.Widget.pick(packetaKey, point => …)` fills hidden
|
||||
`pickup_point_id` + `pickup_point_name`
|
||||
- if the key is empty it falls back to a plain text field
|
||||
|
||||
So to turn it on:
|
||||
|
||||
### a) Set the Web/Widget API key
|
||||
|
||||
Set the env var (read by `config/development.yaml` / `production.yaml` →
|
||||
`settings.packeta_api_key`, exposed via `src/shared/settings.rs`):
|
||||
|
||||
```bash
|
||||
# .env (development) or your production environment
|
||||
PACKETA_API_KEY=your_web_widget_api_key
|
||||
```
|
||||
|
||||
`config/development.yaml` already contains:
|
||||
```yaml
|
||||
settings:
|
||||
packeta_api_key: {{ get_env(name="PACKETA_API_KEY", default="") }}
|
||||
```
|
||||
For production, add the same line under `settings:` in `config/production.yaml`
|
||||
(it isn't there yet).
|
||||
|
||||
### b) Create a Packeta delivery option in the admin
|
||||
|
||||
Go to **`/admin/shipping`** → "Add delivery option":
|
||||
- **Name**: e.g. `Packeta – pickup point`
|
||||
- **Price**: your fee (e.g. `2.90`)
|
||||
- ✅ **Requires pickup point** ← this makes the picker appear at checkout
|
||||
- ✅ **Active**
|
||||
|
||||
The auto-generated `code` will be `packeta-pickup-point` (or similar). Customers
|
||||
now see the option, click "Choose pickup point", pick on the map, and the order
|
||||
stores `pickup_point_id` + `pickup_point_name`.
|
||||
|
||||
**At this point you have a working Packeta flow** — you read the pickup point on
|
||||
the order in `/admin/orders` and create the parcel manually in the Packeta
|
||||
portal. Many small shops stop here.
|
||||
|
||||
---
|
||||
|
||||
## 3. (Optional) Create shipments via API
|
||||
|
||||
Automate "register the parcel + get tracking + print label". Do the
|
||||
[shared groundwork](README.md#shared-groundwork-do-this-once-before-any-carriers-api-step)
|
||||
first (HTTP client, `integrations` module, `carrier` column, tracking columns).
|
||||
|
||||
### Endpoint & auth
|
||||
|
||||
- Packeta REST API base: `https://www.zasilkovna.cz/api/rest` (SOAP also
|
||||
available at `http://www.zasilkovna.cz/api/soap.wsdl`).
|
||||
- Auth = your **API password** (the server secret from step 1), sent in the
|
||||
request body, **not** the widget key.
|
||||
- Key operation: **`createPacket`**. You send sender id, recipient
|
||||
name/email/phone, the chosen **pickup point id** (`addressId`), value, weight,
|
||||
and COD amount; you receive a **packet id + barcode (tracking)**. A separate
|
||||
**`packetLabelPdf`** call returns the label PDF.
|
||||
|
||||
### Store the secret
|
||||
|
||||
```bash
|
||||
PACKETA_API_PASSWORD=your_secret_api_password
|
||||
```
|
||||
Add to `config/*.yaml` under `settings:`:
|
||||
```yaml
|
||||
packeta_api_password: {{ get_env(name="PACKETA_API_PASSWORD", default="") }}
|
||||
```
|
||||
|
||||
### Client sketch (`src/integrations/packeta.rs`)
|
||||
|
||||
```rust
|
||||
use loco_rs::prelude::*;
|
||||
use crate::shared::settings;
|
||||
|
||||
// createPacket accepts XML; serde_json works for the JSON REST variant.
|
||||
pub async fn create_shipment(ctx: &AppContext, req: super::ShipmentRequest<'_>)
|
||||
-> Result<super::ShipmentResult>
|
||||
{
|
||||
let api_password = settings::get(ctx, "packeta_api_password")
|
||||
.ok_or_else(|| Error::string("packeta_api_password not configured"))?;
|
||||
|
||||
// Packeta's createPacket is XML/SOAP-ish; build the body per their docs.
|
||||
// number = your order_number
|
||||
// name/surname = recipient
|
||||
// addressId = req.pickup_point_id (the chosen point)
|
||||
// cod = req.cod_cents / 100 (0 if not COD)
|
||||
// value = goods value
|
||||
// eshop = your sender label/id from the portal
|
||||
let body = format!(r#"<createPacket>
|
||||
<apiPassword>{api_password}</apiPassword>
|
||||
<packetAttributes>
|
||||
<number>{}</number>
|
||||
<name>{}</name>
|
||||
<email>{}</email>
|
||||
<addressId>{}</addressId>
|
||||
<cod>{}</cod>
|
||||
<value>{}</value>
|
||||
<weight>{}</weight>
|
||||
<eshop>YOUR_SENDER_LABEL</eshop>
|
||||
</packetAttributes>
|
||||
</createPacket>"#,
|
||||
req.order_number, req.recipient_name, req.email,
|
||||
req.pickup_point_id.unwrap_or(""),
|
||||
req.cod_cents as f64 / 100.0,
|
||||
req.cod_cents as f64 / 100.0,
|
||||
req.weight_grams);
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.post("https://www.zasilkovna.cz/api/rest")
|
||||
.body(body)
|
||||
.send().await.map_err(|e| Error::string(&e.to_string()))?
|
||||
.text().await.map_err(|e| Error::string(&e.to_string()))?;
|
||||
|
||||
// Parse <id> (packet id) and <barcode> (tracking) out of the XML response.
|
||||
// Then optionally call packetLabelPdf with that id to fetch the label.
|
||||
todo!("parse resp into ShipmentResult")
|
||||
}
|
||||
```
|
||||
|
||||
Then call it from your admin "Create shipment" action for orders whose
|
||||
`shipping_methods.carrier == "packeta"`, and save `tracking_number` /
|
||||
`shipment_id` back on the order.
|
||||
|
||||
---
|
||||
|
||||
## 4. Testing
|
||||
|
||||
- Use the Packeta **sandbox/staging** portal if your account offers one, or a
|
||||
test API password. Verify `createPacket` returns a packet id before going
|
||||
live.
|
||||
- Track the parcel at `https://tracking.packeta.com/...` using the returned
|
||||
barcode.
|
||||
|
||||
## 5. Go-live checklist
|
||||
|
||||
- [ ] `PACKETA_API_KEY` (widget) set in production env
|
||||
- [ ] `packeta_api_key` line added under `settings:` in `config/production.yaml`
|
||||
- [ ] Packeta delivery option created in `/admin/shipping` with **Requires pickup point** ✅
|
||||
- [ ] (If using API) `PACKETA_API_PASSWORD` set + `src/integrations/packeta.rs` implemented
|
||||
- [ ] Sender address & label format configured in the Packeta portal
|
||||
- [ ] Test order → pickup point saved on order → (API) tracking number stored
|
||||
@@ -1,8 +1,11 @@
|
||||
//! Admin management of shipping methods (price + enabled toggle).
|
||||
//! Admin management of shipping methods: add, edit (price + enabled), remove.
|
||||
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter,
|
||||
QueryOrder, Set,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -12,6 +15,7 @@ use crate::{
|
||||
shared::{
|
||||
guard,
|
||||
money::{format_price, parse_price_to_cents},
|
||||
slug::{slugify, unique_slug},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -21,6 +25,18 @@ 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"))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn index(
|
||||
auth: auth::JWT,
|
||||
@@ -53,6 +69,48 @@ 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,
|
||||
@@ -67,13 +125,30 @@ async fn update(
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
let mut active = method.into_active_model();
|
||||
active.price_cents = Set(parse_price_to_cents(&form.price)?);
|
||||
active.enabled = Set(matches!(form.enabled.as_deref(), Some("on" | "true" | "1")));
|
||||
active.enabled = Set(is_checked(&form.enabled));
|
||||
active.update(&ctx.db).await?;
|
||||
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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user