hardcode of dpd and packeta
This commit is contained in:
87
Cargo.lock
generated
87
Cargo.lock
generated
@@ -1504,9 +1504,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 5.3.0",
|
"r-efi 5.3.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1755,6 +1757,22 @@ dependencies = [
|
|||||||
"want",
|
"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]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -2110,6 +2128,7 @@ dependencies = [
|
|||||||
"loco-rs",
|
"loco-rs",
|
||||||
"migration",
|
"migration",
|
||||||
"regex",
|
"regex",
|
||||||
|
"reqwest",
|
||||||
"rstest",
|
"rstest",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2332,6 +2351,12 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac"
|
name = "mac"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -3090,6 +3115,61 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -3297,16 +3377,21 @@ dependencies = [
|
|||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower 0.5.3",
|
"tower 0.5.3",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@@ -3316,6 +3401,7 @@ dependencies = [
|
|||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams",
|
"wasm-streams",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
"webpki-roots 1.0.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3520,6 +3606,7 @@ version = "1.14.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ dotenvy = { version = "0.15" }
|
|||||||
validator = { version = "0.20" }
|
validator = { version = "0.20" }
|
||||||
uuid = { version = "1.6", features = ["v4"] }
|
uuid = { version = "1.6", features = ["v4"] }
|
||||||
include_dir = { version = "0.7" }
|
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
|
# view engine i18n
|
||||||
fluent-templates = { version = "0.13", features = ["tera"] }
|
fluent-templates = { version = "0.13", features = ["tera"] }
|
||||||
unic-langid = { version = "0.9" }
|
unic-langid = { version = "0.9" }
|
||||||
|
|||||||
@@ -261,8 +261,21 @@ bank-account-name = Account holder
|
|||||||
bank-variable-symbol = Variable symbol
|
bank-variable-symbol = Variable symbol
|
||||||
bank-amount = Amount
|
bank-amount = Amount
|
||||||
admin-shipping = Shipping
|
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-enabled = Active
|
||||||
shipping-new = Add delivery option
|
shipping-new = Add delivery option
|
||||||
shipping-add = Add
|
shipping-add = Add
|
||||||
shipping-requires-pickup = Requires pickup point
|
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?
|
||||||
|
|||||||
@@ -261,8 +261,21 @@ bank-account-name = Príjemca
|
|||||||
bank-variable-symbol = Variabilný symbol
|
bank-variable-symbol = Variabilný symbol
|
||||||
bank-amount = Suma
|
bank-amount = Suma
|
||||||
admin-shipping = Doprava
|
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-enabled = Aktívne
|
||||||
shipping-new = Pridať možnosť dopravy
|
shipping-new = Pridať možnosť dopravy
|
||||||
shipping-add = Pridať
|
shipping-add = Pridať
|
||||||
shipping-requires-pickup = Vyžaduje výdajné miesto
|
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?
|
||||||
|
|||||||
@@ -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>
|
<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>
|
</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="mt-6 grid gap-6 lg:grid-cols-3">
|
||||||
<div class="space-y-6 lg:col-span-2">
|
<div class="space-y-6 lg:col-span-2">
|
||||||
<div class="overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
|
<div class="overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
|
||||||
@@ -69,6 +75,33 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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">
|
<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>
|
<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"
|
<select id="status" name="status"
|
||||||
|
|||||||
@@ -11,11 +11,11 @@
|
|||||||
|
|
||||||
<div class="mt-6 space-y-4">
|
<div class="mt-6 space-y-4">
|
||||||
{% for method in methods %}
|
{% 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 }}"
|
||||||
<form method="post" action="/admin/shipping/{{ method.id }}" class="flex flex-1 flex-wrap items-end gap-4">
|
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">
|
<div class="min-w-40">
|
||||||
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ method.name }}</p>
|
<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>
|
||||||
<div class="space-y-1.5">
|
<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>
|
<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')) }}
|
{{ t(key="save", lang=lang | default(value='sk')) }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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 %}
|
{% endfor %}
|
||||||
</div>
|
</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 %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -108,6 +108,20 @@ settings:
|
|||||||
# Packeta (Zásilkovna) web API key for the pickup-point picker widget.
|
# Packeta (Zásilkovna) web API key for the pickup-point picker widget.
|
||||||
# Empty falls back to a plain text field for the pickup point.
|
# Empty falls back to a plain text field for the pickup point.
|
||||||
packeta_api_key: {{ get_env(name="PACKETA_API_KEY", default="") }}
|
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-transfer payment details shown on the order confirmation.
|
||||||
bank_iban: {{ get_env(name="BANK_IBAN", default="SK00 0000 0000 0000 0000 0000") }}
|
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.") }}
|
bank_account_name: {{ get_env(name="BANK_ACCOUNT_NAME", default="Kompress s.r.o.") }}
|
||||||
|
|||||||
@@ -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 |
|
| 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 |
|
| 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 |
|
| Packeta pickup-point widget | `assets/views/shop/checkout.html` (loads when `packeta_api_key` set) | ✅ scaffolded |
|
||||||
| Shipment-creation API client (any carrier) | — | ❌ not built |
|
| `shipping_methods.carrier` (which API a method maps to) | `migration/.../m20260617_000001_*` + admin add-form dropdown | ✅ done |
|
||||||
| Tracking number on order | — | ❌ not built |
|
| 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
|
**Shipments are created only when an admin clicks "Send to carrier" on the order
|
||||||
API key. Everything else (DPD/DHL widgets, and *all* shipment-creation API
|
page** — never automatically at checkout. Packeta is wired end-to-end (needs
|
||||||
calls) is new work, described per carrier.
|
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)
|
## Shared groundwork (do this once, before any carrier's API step)
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ mod m20260616_132000_drop_blog_and_pages;
|
|||||||
mod m20260616_150755_shipping_methods;
|
mod m20260616_150755_shipping_methods;
|
||||||
mod m20260616_150812_add_shipping_fields_to_orders;
|
mod m20260616_150812_add_shipping_fields_to_orders;
|
||||||
mod m20260616_160000_add_parent_to_categories;
|
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;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -58,6 +60,8 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260616_150755_shipping_methods::Migration),
|
Box::new(m20260616_150755_shipping_methods::Migration),
|
||||||
Box::new(m20260616_150812_add_shipping_fields_to_orders::Migration),
|
Box::new(m20260616_150812_add_shipping_fields_to_orders::Migration),
|
||||||
Box::new(m20260616_160000_add_parent_to_categories::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)
|
// inject-above (do not remove this comment)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
23
migration/src/m20260617_000002_add_shipment_to_orders.rs
Normal file
23
migration/src/m20260617_000002_add_shipment_to_orders.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ impl Hooks for App {
|
|||||||
Ok(vec![
|
Ok(vec![
|
||||||
Box::new(initializers::view_engine::ViewEngineInitializer),
|
Box::new(initializers::view_engine::ViewEngineInitializer),
|
||||||
Box::new(initializers::admin_seeder::AdminSeeder),
|
Box::new(initializers::admin_seeder::AdminSeeder),
|
||||||
|
Box::new(initializers::shipping_seeder::ShippingSeeder),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Admin order list, detail, and status updates.
|
//! Admin order list, detail, status updates, and manual carrier dispatch.
|
||||||
|
|
||||||
use axum_extra::extract::cookie::CookieJar;
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
@@ -7,7 +7,8 @@ use serde::Deserialize;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
models::{order_items, orders},
|
integrations::{self, ShipmentRequest},
|
||||||
|
models::{order_items, orders, shipping_methods},
|
||||||
views::checkout as view,
|
views::checkout as view,
|
||||||
controllers::i18n::current_lang,
|
controllers::i18n::current_lang,
|
||||||
shared::{guard, settings},
|
shared::{guard, settings},
|
||||||
@@ -15,6 +16,9 @@ use crate::{
|
|||||||
|
|
||||||
pub(crate) const ORDER_STATUSES: [&str; 4] = ["pending", "paid", "shipped", "cancelled"];
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct StatusForm {
|
struct StatusForm {
|
||||||
status: String,
|
status: String,
|
||||||
@@ -40,15 +44,28 @@ async fn index(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
/// Resolve the carrier code (`none`/`packeta`/`dpd`/`dhl`) for an order from its
|
||||||
async fn show(
|
/// chosen shipping method, defaulting to `none` when unknown.
|
||||||
auth: auth::JWT,
|
async fn order_carrier(ctx: &AppContext, order: &orders::Model) -> Result<String> {
|
||||||
jar: CookieJar,
|
let Some(code) = order.carrier_code.as_deref() else {
|
||||||
ViewEngine(v): ViewEngine<TeraView>,
|
return Ok("none".to_string());
|
||||||
Path(id): Path<i32>,
|
};
|
||||||
State(ctx): State<AppContext>,
|
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> {
|
) -> Result<Response> {
|
||||||
guard::current_admin(auth, &ctx).await?;
|
|
||||||
let order = orders::Entity::find_by_id(id)
|
let order = orders::Entity::find_by_id(id)
|
||||||
.one(&ctx.db)
|
.one(&ctx.db)
|
||||||
.await?
|
.await?
|
||||||
@@ -58,22 +75,42 @@ async fn show(
|
|||||||
.all(&ctx.db)
|
.all(&ctx.db)
|
||||||
.await?;
|
.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(
|
format::view(
|
||||||
&v,
|
v,
|
||||||
"admin/orders/show.html",
|
"admin/orders/show.html",
|
||||||
json!({
|
json!({
|
||||||
"order": view::detail(
|
"order": view::detail(
|
||||||
&order,
|
&order,
|
||||||
settings::get(&ctx, "bank_iban").unwrap_or(""),
|
settings::get(ctx, "bank_iban").unwrap_or(""),
|
||||||
settings::get(&ctx, "bank_account_name").unwrap_or(""),
|
settings::get(ctx, "bank_account_name").unwrap_or(""),
|
||||||
),
|
),
|
||||||
"items": view::items(&items),
|
"items": view::items(&items),
|
||||||
"statuses": ORDER_STATUSES,
|
"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]
|
#[debug_handler]
|
||||||
async fn update_status(
|
async fn update_status(
|
||||||
auth: auth::JWT,
|
auth: auth::JWT,
|
||||||
@@ -96,9 +133,82 @@ async fn update_status(
|
|||||||
format::redirect(&format!("/admin/orders/{id}"))
|
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 {
|
pub fn routes() -> Routes {
|
||||||
Routes::new()
|
Routes::new()
|
||||||
.add("/admin/orders", get(index))
|
.add("/admin/orders", get(index))
|
||||||
.add("/admin/orders/{id}", get(show))
|
.add("/admin/orders/{id}", get(show))
|
||||||
.add("/admin/orders/{id}/status", post(update_status))
|
.add("/admin/orders/{id}/status", post(update_status))
|
||||||
|
.add("/admin/orders/{id}/ship", post(ship))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
//! Admin management of shipping methods: add, edit (price + enabled), remove.
|
//! Admin management of the built-in delivery options (Packeta, DPD).
|
||||||
|
//!
|
||||||
|
//! The options themselves are fixed and seeded by `initializers::shipping_seeder`
|
||||||
|
//! — they cannot be added or removed here. The admin only sets each one's price
|
||||||
|
//! and toggles whether it is offered at checkout.
|
||||||
|
|
||||||
use axum_extra::extract::cookie::CookieJar;
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use sea_orm::{
|
use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set};
|
||||||
ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter,
|
|
||||||
QueryOrder, Set,
|
|
||||||
};
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
@@ -15,7 +16,6 @@ use crate::{
|
|||||||
shared::{
|
shared::{
|
||||||
guard,
|
guard,
|
||||||
money::{format_price, parse_price_to_cents},
|
money::{format_price, parse_price_to_cents},
|
||||||
slug::{slugify, unique_slug},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -25,14 +25,6 @@ struct ShippingForm {
|
|||||||
enabled: Option<String>,
|
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 {
|
fn is_checked(value: &Option<String>) -> bool {
|
||||||
matches!(value.as_deref(), Some("on" | "true" | "1"))
|
matches!(value.as_deref(), Some("on" | "true" | "1"))
|
||||||
}
|
}
|
||||||
@@ -57,6 +49,7 @@ async fn index(
|
|||||||
"code": m.code,
|
"code": m.code,
|
||||||
"name": m.name,
|
"name": m.name,
|
||||||
"price": format_price(m.price_cents),
|
"price": format_price(m.price_cents),
|
||||||
|
"carrier": m.carrier,
|
||||||
"requires_pickup_point": m.requires_pickup_point,
|
"requires_pickup_point": m.requires_pickup_point,
|
||||||
"enabled": m.enabled,
|
"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]
|
#[debug_handler]
|
||||||
async fn update(
|
async fn update(
|
||||||
auth: auth::JWT,
|
auth: auth::JWT,
|
||||||
@@ -130,25 +81,8 @@ async fn update(
|
|||||||
format::redirect("/admin/shipping")
|
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 {
|
pub fn routes() -> Routes {
|
||||||
Routes::new()
|
Routes::new()
|
||||||
.add("/admin/shipping", get(index))
|
.add("/admin/shipping", get(index))
|
||||||
.add("/admin/shipping", post(create))
|
|
||||||
.add("/admin/shipping/{id}", post(update))
|
.add("/admin/shipping/{id}", post(update))
|
||||||
.add("/admin/shipping/{id}/delete", post(delete))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
pub mod admin_seeder;
|
pub mod admin_seeder;
|
||||||
|
pub mod shipping_seeder;
|
||||||
pub mod view_engine;
|
pub mod view_engine;
|
||||||
|
|||||||
54
src/initializers/shipping_seeder.rs
Normal file
54
src/initializers/shipping_seeder.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
//! Ensures the built-in carrier delivery options (Packeta, DPD) always exist.
|
||||||
|
//!
|
||||||
|
//! These are the only delivery options the shop offers; the admin can price and
|
||||||
|
//! enable/disable them but cannot add or remove options. We insert each one
|
||||||
|
//! only when its `code` is missing, so an admin's price/enabled changes are
|
||||||
|
//! never overwritten on the next boot.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||||
|
|
||||||
|
use crate::models::shipping_methods;
|
||||||
|
|
||||||
|
/// `(code, name, carrier, requires_pickup_point, default_price_cents, position)`
|
||||||
|
const BUILTINS: [(&str, &str, &str, bool, i64, i32); 2] = [
|
||||||
|
("packeta", "Packeta", "packeta", true, 290, 0),
|
||||||
|
("dpd", "DPD", "dpd", false, 450, 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
pub struct ShippingSeeder;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Initializer for ShippingSeeder {
|
||||||
|
fn name(&self) -> String {
|
||||||
|
"shipping-seeder".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn before_run(&self, ctx: &AppContext) -> Result<()> {
|
||||||
|
for (code, name, carrier, requires_pickup_point, price_cents, position) in BUILTINS {
|
||||||
|
let exists = shipping_methods::Entity::find()
|
||||||
|
.filter(shipping_methods::Column::Code.eq(code))
|
||||||
|
.count(&ctx.db)
|
||||||
|
.await?
|
||||||
|
> 0;
|
||||||
|
if exists {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
shipping_methods::ActiveModel {
|
||||||
|
code: Set(code.to_string()),
|
||||||
|
name: Set(name.to_string()),
|
||||||
|
carrier: Set(carrier.to_string()),
|
||||||
|
requires_pickup_point: Set(requires_pickup_point),
|
||||||
|
price_cents: Set(price_cents),
|
||||||
|
enabled: Set(true),
|
||||||
|
position: Set(position),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
tracing::info!(carrier = code, "seeded built-in delivery option");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/integrations/dhl.rs
Normal file
35
src/integrations/dhl.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
//! DHL shipment creation. See `docs/integrations/dhl.md`.
|
||||||
|
//!
|
||||||
|
//! DHL has several APIs (Parcel DE Shipping, eCommerce, MyDHL Express) behind
|
||||||
|
//! one developer portal; which one applies depends on your contract and the
|
||||||
|
//! markets you ship to. As with DPD, the workflow is fully wired — only the
|
||||||
|
//! authenticated HTTP call is left as a marked TODO so we don't ship an
|
||||||
|
//! unverified payload.
|
||||||
|
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
|
||||||
|
use super::{ShipmentRequest, ShipmentResult};
|
||||||
|
use crate::shared::settings;
|
||||||
|
|
||||||
|
pub async fn create_shipment(ctx: &AppContext, _req: ShipmentRequest<'_>) -> Result<ShipmentResult> {
|
||||||
|
let _base = settings::get(ctx, "dhl_api_base").filter(|s| !s.is_empty());
|
||||||
|
let _key = settings::get(ctx, "dhl_api_key").filter(|s| !s.is_empty());
|
||||||
|
let _secret = settings::get(ctx, "dhl_api_secret").filter(|s| !s.is_empty());
|
||||||
|
let _account = settings::get(ctx, "dhl_account_number").filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
if _base.is_none() || _key.is_none() || _secret.is_none() || _account.is_none() {
|
||||||
|
return Err(Error::BadRequest(
|
||||||
|
"DHL is not configured: set settings.dhl_api_base / dhl_api_key / dhl_api_secret / dhl_account_number (see docs/integrations/dhl.md)".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(dhl): implement once the API subscription is known:
|
||||||
|
// 1. OAuth2 client-credentials -> Bearer token (cache until expiry).
|
||||||
|
// 2. POST the shipment: shipper = your account/EKP, consignee from
|
||||||
|
// `_req`, product code (domestic/express), weight, references; add
|
||||||
|
// customs data for non-EU destinations.
|
||||||
|
// 3. Parse tracking number + label, return ShipmentResult.
|
||||||
|
Err(Error::BadRequest(
|
||||||
|
"DHL shipment API not finalised yet — fill in the request in src/integrations/dhl.rs per your DHL subscription (docs/integrations/dhl.md)".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
38
src/integrations/dpd.rs
Normal file
38
src/integrations/dpd.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
//! DPD shipment creation. See `docs/integrations/dpd.md`.
|
||||||
|
//!
|
||||||
|
//! DPD's API (REST vs SOAP, base URL, exact field names) depends on your
|
||||||
|
//! country and contract, so the request body below must be finalised against
|
||||||
|
//! *your* DPD account before going live. The surrounding workflow — admin
|
||||||
|
//! trigger, tracking storage, status update — is fully wired; only the HTTP
|
||||||
|
//! call is left as a clearly-marked TODO so we don't ship an unverified payload
|
||||||
|
//! that silently produces broken shipments.
|
||||||
|
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
|
||||||
|
use super::{ShipmentRequest, ShipmentResult};
|
||||||
|
use crate::shared::settings;
|
||||||
|
|
||||||
|
pub async fn create_shipment(ctx: &AppContext, _req: ShipmentRequest<'_>) -> Result<ShipmentResult> {
|
||||||
|
// Required settings (add to config/*.yaml under `settings:` — see docs).
|
||||||
|
let _base = settings::get(ctx, "dpd_api_base").filter(|s| !s.is_empty());
|
||||||
|
let _login = settings::get(ctx, "dpd_login").filter(|s| !s.is_empty());
|
||||||
|
let _password = settings::get(ctx, "dpd_password").filter(|s| !s.is_empty());
|
||||||
|
let _customer = settings::get(ctx, "dpd_customer_number").filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
if _base.is_none() || _login.is_none() || _password.is_none() || _customer.is_none() {
|
||||||
|
return Err(Error::BadRequest(
|
||||||
|
"DPD is not configured: set settings.dpd_api_base / dpd_login / dpd_password / dpd_customer_number (see docs/integrations/dpd.md)".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(dpd): implement once the contract's API variant is known:
|
||||||
|
// 1. POST login (delisId + password) -> auth token (cache it).
|
||||||
|
// 2. POST storeOrders with the token: recipient from `_req` (or
|
||||||
|
// parcelShopId = _req.pickup_point_id for DPD Pickup), weight,
|
||||||
|
// cod = _req.cod_cents, reference = _req.order_number.
|
||||||
|
// 3. Parse the returned parcel number (tracking) + label, then return:
|
||||||
|
// Ok(ShipmentResult { shipment_id, tracking_number, label_url })
|
||||||
|
Err(Error::BadRequest(
|
||||||
|
"DPD shipment API not finalised yet — fill in the request in src/integrations/dpd.rs per your DPD contract (docs/integrations/dpd.md)".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
65
src/integrations/mod.rs
Normal file
65
src/integrations/mod.rs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
//! Outbound carrier integrations for creating shipments.
|
||||||
|
//!
|
||||||
|
//! Shipments are **never created automatically**. An admin reviews an order and,
|
||||||
|
//! once the goods are physically ready, explicitly triggers
|
||||||
|
//! [`create_shipment`] from the order page. Only then does the eshop call the
|
||||||
|
//! carrier's API. `orders::place` (checkout) does not touch any of this.
|
||||||
|
//!
|
||||||
|
//! Each delivery option (`shipping_methods.carrier`) maps to one carrier here.
|
||||||
|
//! "none" means the option is fulfilled manually and has no API.
|
||||||
|
|
||||||
|
pub mod dhl;
|
||||||
|
pub mod dpd;
|
||||||
|
pub mod packeta;
|
||||||
|
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
|
||||||
|
/// Everything a carrier needs to register a parcel, snapshotted from an order.
|
||||||
|
pub struct ShipmentRequest<'a> {
|
||||||
|
pub order_number: &'a str,
|
||||||
|
pub recipient_name: &'a str,
|
||||||
|
pub email: &'a str,
|
||||||
|
pub address: Option<&'a str>,
|
||||||
|
pub city: Option<&'a str>,
|
||||||
|
pub zip: Option<&'a str>,
|
||||||
|
pub country: Option<&'a str>,
|
||||||
|
/// Carrier pickup-point / locker id, when the method requires one.
|
||||||
|
pub pickup_point_id: Option<&'a str>,
|
||||||
|
/// Cash-on-delivery amount in cents; `0` when payment is not COD.
|
||||||
|
pub cod_cents: i64,
|
||||||
|
pub currency: &'a str,
|
||||||
|
/// Total order value in cents (for insurance / customs declarations).
|
||||||
|
pub value_cents: i64,
|
||||||
|
pub weight_grams: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a carrier returns once the parcel is registered.
|
||||||
|
pub struct ShipmentResult {
|
||||||
|
/// Carrier-internal shipment/packet id.
|
||||||
|
pub shipment_id: String,
|
||||||
|
/// Public tracking number / barcode shown to the customer.
|
||||||
|
pub tracking_number: String,
|
||||||
|
/// Direct link to the shipping label PDF, if the carrier returns one.
|
||||||
|
pub label_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch to the carrier named by `shipping_methods.carrier`. Returns an error
|
||||||
|
/// for `"none"` (manual fulfilment) or an unknown carrier.
|
||||||
|
pub async fn create_shipment(
|
||||||
|
ctx: &AppContext,
|
||||||
|
carrier: &str,
|
||||||
|
req: ShipmentRequest<'_>,
|
||||||
|
) -> Result<ShipmentResult> {
|
||||||
|
match carrier {
|
||||||
|
"packeta" => packeta::create_shipment(ctx, req).await,
|
||||||
|
"dpd" => dpd::create_shipment(ctx, req).await,
|
||||||
|
"dhl" => dhl::create_shipment(ctx, req).await,
|
||||||
|
"none" | "" => Err(Error::BadRequest(
|
||||||
|
"this delivery option is fulfilled manually (no carrier API)".to_string(),
|
||||||
|
)),
|
||||||
|
other => Err(Error::BadRequest(format!("unknown carrier '{other}'"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The carrier values offered in the admin UI. `none` is the manual default.
|
||||||
|
pub const CARRIERS: [&str; 4] = ["none", "packeta", "dpd", "dhl"];
|
||||||
114
src/integrations/packeta.rs
Normal file
114
src/integrations/packeta.rs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
//! Packeta (Zásilkovna) shipment creation via the REST `createPacket` call.
|
||||||
|
//!
|
||||||
|
//! See `docs/integrations/packeta.md`. Requires two settings:
|
||||||
|
//! - `packeta_api_password` — the secret REST API password (NOT the widget key)
|
||||||
|
//! - `packeta_sender_label` — your sender/eshop label configured in the portal
|
||||||
|
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
|
||||||
|
use super::{ShipmentRequest, ShipmentResult};
|
||||||
|
use crate::shared::settings;
|
||||||
|
|
||||||
|
const ENDPOINT: &str = "https://www.zasilkovna.cz/api/rest";
|
||||||
|
|
||||||
|
/// Minimal XML-entity escaping for values interpolated into the request body.
|
||||||
|
fn xml_escape(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the text inside the first `<tag>…</tag>` pair, if present.
|
||||||
|
fn extract(xml: &str, tag: &str) -> Option<String> {
|
||||||
|
let open = format!("<{tag}>");
|
||||||
|
let close = format!("</{tag}>");
|
||||||
|
let start = xml.find(&open)? + open.len();
|
||||||
|
let end = xml[start..].find(&close)? + start;
|
||||||
|
Some(xml[start..end].trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_shipment(ctx: &AppContext, req: ShipmentRequest<'_>) -> Result<ShipmentResult> {
|
||||||
|
let api_password = settings::get(ctx, "packeta_api_password").filter(|s| !s.is_empty()).ok_or_else(|| {
|
||||||
|
Error::BadRequest(
|
||||||
|
"Packeta is not configured: set settings.packeta_api_password (see docs/integrations/packeta.md)".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let sender_label = settings::get(ctx, "packeta_sender_label").filter(|s| !s.is_empty()).ok_or_else(|| {
|
||||||
|
Error::BadRequest(
|
||||||
|
"Packeta is not configured: set settings.packeta_sender_label (see docs/integrations/packeta.md)".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// The scaffolded checkout flow delivers to a Packeta pickup point, so an
|
||||||
|
// address id is required. (Home delivery uses a different routing flow.)
|
||||||
|
let address_id = req.pickup_point_id.filter(|s| !s.is_empty()).ok_or_else(|| {
|
||||||
|
Error::BadRequest("this order has no Packeta pickup point selected".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let value = req.value_cents as f64 / 100.0;
|
||||||
|
let cod = req.cod_cents as f64 / 100.0;
|
||||||
|
let weight_kg = f64::from(req.weight_grams) / 1000.0;
|
||||||
|
|
||||||
|
let body = format!(
|
||||||
|
"<createPacket>\
|
||||||
|
<apiPassword>{}</apiPassword>\
|
||||||
|
<packetAttributes>\
|
||||||
|
<number>{}</number>\
|
||||||
|
<name>{}</name>\
|
||||||
|
<surname>-</surname>\
|
||||||
|
<email>{}</email>\
|
||||||
|
<addressId>{}</addressId>\
|
||||||
|
<value>{:.2}</value>\
|
||||||
|
<cod>{:.2}</cod>\
|
||||||
|
<currency>{}</currency>\
|
||||||
|
<weight>{:.3}</weight>\
|
||||||
|
<eshop>{}</eshop>\
|
||||||
|
</packetAttributes>\
|
||||||
|
</createPacket>",
|
||||||
|
xml_escape(api_password),
|
||||||
|
xml_escape(req.order_number),
|
||||||
|
xml_escape(req.recipient_name),
|
||||||
|
xml_escape(req.email),
|
||||||
|
xml_escape(address_id),
|
||||||
|
value,
|
||||||
|
cod,
|
||||||
|
xml_escape(req.currency),
|
||||||
|
weight_kg,
|
||||||
|
xml_escape(sender_label),
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.post(ENDPOINT)
|
||||||
|
.header("Content-Type", "text/xml; charset=utf-8")
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::string(&format!("Packeta request failed: {e}")))?;
|
||||||
|
|
||||||
|
let text = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::string(&format!("Packeta response read failed: {e}")))?;
|
||||||
|
|
||||||
|
// A successful response is <response><status>ok</status><result>…</result>.
|
||||||
|
// A failure carries <status>fault</status> plus a <string>/<fault> message.
|
||||||
|
if extract(&text, "status").as_deref() != Some("ok") {
|
||||||
|
let message = extract(&text, "string")
|
||||||
|
.or_else(|| extract(&text, "fault"))
|
||||||
|
.unwrap_or_else(|| "unknown Packeta error".to_string());
|
||||||
|
return Err(Error::BadRequest(format!("Packeta rejected the shipment: {message}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let shipment_id = extract(&text, "id")
|
||||||
|
.ok_or_else(|| Error::string("Packeta response missing packet id"))?;
|
||||||
|
let tracking_number = extract(&text, "barcode").unwrap_or_else(|| shipment_id.clone());
|
||||||
|
|
||||||
|
Ok(ShipmentResult {
|
||||||
|
label_url: Some(format!("https://tracking.packeta.com/sk/?id={tracking_number}")),
|
||||||
|
shipment_id,
|
||||||
|
tracking_number,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ pub mod app;
|
|||||||
pub mod controllers;
|
pub mod controllers;
|
||||||
pub mod data;
|
pub mod data;
|
||||||
pub mod initializers;
|
pub mod initializers;
|
||||||
|
pub mod integrations;
|
||||||
pub mod mailers;
|
pub mod mailers;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod seed;
|
pub mod seed;
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ pub struct Model {
|
|||||||
pub shipping_cents: i64,
|
pub shipping_cents: i64,
|
||||||
pub pickup_point_id: Option<String>,
|
pub pickup_point_id: Option<String>,
|
||||||
pub pickup_point_name: 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)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub struct Model {
|
|||||||
pub requires_pickup_point: bool,
|
pub requires_pickup_point: bool,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub position: i32,
|
pub position: i32,
|
||||||
|
pub carrier: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ pub fn detail(order: &orders::Model, bank_iban: &str, bank_account_name: &str) -
|
|||||||
"payment_method": order.payment_method,
|
"payment_method": order.payment_method,
|
||||||
"carrier_name": order.carrier_name,
|
"carrier_name": order.carrier_name,
|
||||||
"pickup_point_name": order.pickup_point_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.
|
// Numeric, sequential order id doubles as the bank variable symbol.
|
||||||
"variable_symbol": order.id,
|
"variable_symbol": order.id,
|
||||||
"bank_iban": bank_iban,
|
"bank_iban": bank_iban,
|
||||||
|
|||||||
Reference in New Issue
Block a user