# Magazynowanie i wydanie

API magazynowania i wydania umożliwia kontu klienta firmy magazynowej przyjęcie towaru na przechowanie, opłacenie okresu magazynowania, a następnie wysyłkę przechowywanych paczek do własnych nabywców. Jest przeznaczone dla sprzedawców i platform, które utrzymują zapasy w magazynie zewnętrznym (3PL) i chcą automatyzować z własnych systemów rezerwację magazynowania, podgląd stanów oraz przesyłki wychodzące. Wszystkie wywołania są wykonywane w imieniu konta klienta, nigdy w imieniu firmy magazynowej.

## 1. Co można zbudować

Przykłady w tym przewodniku opierają się na jednym scenariuszu. **Northwind Outdoor**, sezonowy sprzedawca internetowy odzieży i sprzętu zimowego, przechowuje zapasy zimowe w magazynie **Toronto Hub** (magazyn `7`) swojego operatora 3PL od 1 listopada 2026 r. do 31 marca 2027 r. Gdy nabywca zamawia karton kurtek ocieplanych, Northwind wysyła ten karton z zapasów do nabywcy w Ottawie.

- **Sezonowa rezerwacja magazynowania.** System zaplecza sprzedawcy wycenia i rezerwuje okres magazynowania dla każdego przychodzącego kartonu, zanim towar opuści dostawcę, i opłaca opłatę magazynową z salda konta.
- **Bieżący podgląd stanów.** Sklep lub system ERP sprzedawcy wyświetla paczki, które magazyn faktycznie przyjął i które są nadal dostępne do wysyłki, dzięki czemu do realizacji oferowane są wyłącznie rzeczywiste zapasy.
- **Realizacja zamówień z zapasów.** Gdy nabywca składa zamówienie, system sprzedawcy wycenia przesyłkę wychodzącą, tworzy zlecenie wydania dla przechowywanych paczek, opłaca je i zapisuje numer śledzenia dla nabywcy.
- **Kontrola statusu i korekty.** System sprzedawcy odczytuje stan każdego zamówienia magazynowego i każdego wydania, śledzi przesyłkę przez publiczne śledzenie i anuluje wydanie, które nie jest już potrzebne, dopóki jest to jeszcze dozwolone.

## 2. Zakres tego przewodnika

Ten przewodnik należy stosować, gdy towar jest już lub będzie przechowywany w magazynie firmy, a przesyłka rozpoczyna się z tych zapasów. Przebieg: logowanie → odczyt konfiguracji magazynowania → wycena magazynowania → utworzenie zamówienia magazynowego → płatność → lista paczek na stanie → lista usług i wycena wydania → utworzenie wydania → płatność → odczyt i śledzenie → webhooki → anulowanie.

Do innych przypadków służą inne przewodniki:

- **Uniorder: jedno API dla każdej przesyłki** — zalecany pojedynczy punkt wejścia (`/api/v1/uniorder/...`) dla nowych integracji rezerwujących dostawę lokalną lub etykiety przewoźników. Uniorder **nie** obejmuje magazynowania i wydania; zamówienia magazynowe i wydania tworzy się wyłącznie przez endpointy klienta opisane w tym przewodniku.
- **Usługi wysyłkowe** — klient wysyła towar, który nie jest magazynowany, korzystając z usług wysyłkowych firmy.
- **Etykiety przewoźnika** — firma kupuje etykiety przewoźników bezpośrednio dla własnych paczek.
- **Odbiór i dostawa (własna flota)** — firma rezerwuje odbiory i dostawy realizowane własną flotą.

## 3. Przed rozpoczęciem

- **Typ konta.** Konto **klienta** firmy magazynowej (firma prowadząca magazyn jest usługodawcą). Token konta firmy (klienta platformy) nie działa na endpointach `/api/v1/customer/...`.
- **Uprawnienia.** Konto klienta wymaga dostępu do API. Endpointy magazynowania wymagają ponadto uprawnienia do magazynowania; endpointy wydania wymagają, aby firma włączyła wydanie (lub konsolidację) dla tego klienta, w przeciwnym razie zwracają `403`.
- **Saldo.** Płatności za magazynowanie i wydanie są pobierane z salda konta klienta. Na potrzeby testu należy poprosić firmę o zasilenie salda testowego klienta.
- **Dane testowe.** `id` magazynu, co najmniej jedno `id` opakowania, jeśli paczki niestandardowe nie są dozwolone, oraz co najmniej jedna aktywna usługa wysyłkowa dostępna z tego magazynu. Wydanie działa dopiero po **przyjęciu** przechowywanych paczek przez magazyn; podczas testu należy poprosić personel magazynu o przyjęcie testowego zamówienia magazynowego.
- **Obsługa tokenu.** Logowanie należy wykonywać z serwera, token przechowywać na serwerze i nigdy nie umieszczać go w kodzie przeglądarki ani aplikacji mobilnej.
- **Symbole zastępcze.** `YOUR_HOST` należy zastąpić hostem platformy, a `ACCESS_TOKEN` tokenem uzyskanym w kroku 4.

## 4. Logowanie jako klient

Każde kolejne wywołanie jest autoryzowane tokenem bearer klienta. Integracja loguje się jeden raz, przechowuje token po stronie serwera i odnawia go przed `expires_at`.

**REST:** `POST /api/v1/user/customer/login` — [Podręcznik REST](/api/documentation#/paths/v1-user-customer-login/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/user/customer/login \
  -H "Content-Type: application/json" \
  -d '{"email":"ops@northwind-outdoor.example","password":"your_password"}'
```

```json
{
  "result": true,
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOi...",
  "token_type": "Bearer",
  "expires_at": "2027-09-28 10:15:00",
  "expires_timestamp": 1822040100,
  "name": "Northwind Outdoor"
}
```

- `access_token` — należy go wysyłać w każdym żądaniu w poniższym nagłówku.
- `expires_at` / `expires_timestamp` — przed tym czasem należy zalogować się ponownie.

```
Authorization: Bearer ACCESS_TOKEN
```

GraphQL używa tego samego nagłówka na `POST /api/graphql`.

**Weryfikacja:** logowanie zwraca `access_token`. Kolejne żądania bez tego tokenu zwracają `401`.

## 5. Odczyt konfiguracji magazynowania

Pakiet konfiguracji wymienia magazyny, z których klient może korzystać, katalog opakowań, jednostki i dopłaty. Integracja odczytuje go raz na sesję, aby wybrać magazyn i zbudować prawidłowe pozycje paczek.

**REST:** `GET /api/v1/customer/storage-orders/config` — [Podręcznik REST](/api/documentation#/paths/v1-customer-storage-orders-config/get)

```bash
curl https://YOUR_HOST/api/v1/customer/storage-orders/config \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "result": true,
  "data": {
    "warehouses": [
      { "id": 7, "name": "Toronto Hub", "address": "10 Main St", "city": "Toronto", "province": "ON", "postcode": "M5V 2T6" }
    ],
    "packagings": [
      { "id": 1, "name": "Large Carton", "type": "Box", "length": 60, "width": 40, "height": 40, "dimension_unit": 2 }
    ],
    "dimension_units": { "1": { "name": "dimension_mm" }, "2": { "name": "dimension_cm" }, "3": { "name": "dimension_m" }, "4": { "name": "dimension_inch" } },
    "weight_units": { "1": { "name": "weight_g" }, "2": { "name": "weight_kg" }, "3": { "name": "weight_oz" }, "4": { "name": "weight_lb" } },
    "allow_custom_package": true,
    "surcharges": [],
    "form_bindings": []
  }
}
```

- `warehouses[].id` — `warehouse_id` do każdego kolejnego wywołania.
- `allow_custom_package` — gdy `false`, każda pozycja magazynowa musi zawierać `packaging_id` z `packagings[]`; gdy `true`, pozycje można opisać wyłącznie wymiarami.
- `dimension_units` / `weight_units` — kody liczbowe używane w pozycjach paczek (`2` = cm, `2` = kg).
- `form_bindings` — formularze wymagane przez firmę przy zamówieniu magazynowym; odpowiedzi należy przesłać jako `form_data` w kroku 7.

**GraphQL:** `customerStorageOrderConfig` ([Podręcznik GraphQL](/api/graphql/documentation#/customer/customerStorageOrderConfig))

```graphql
query {
  customerStorageOrderConfig
}
```

**Weryfikacja:** zapisano `id` magazynu oraz, jeśli katalog nie jest pusty, `id` opakowania.

## 6. Wycena okresu magazynowania

Wycena ustala cenę okresu magazynowania dla planowanych paczek, zanim cokolwiek zostanie zarezerwowane. Integracja wyświetla lub sprawdza tę cenę, a następnie tworzy zamówienie z tymi samymi danymi wejściowymi.

**REST:** `POST /api/v1/customer/storage-orders/calculate-price` — [Podręcznik REST](/api/documentation#/paths/v1-customer-storage-orders-calculate-price/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/storage-orders/calculate-price \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "warehouse_id": 7,
    "start_date": "2026-11-01",
    "end_date": "2027-03-31",
    "items": [{
      "qty": 2,
      "length": 60,
      "width": 40,
      "height": 40,
      "dimension_unit": 2,
      "weight": 14,
      "weight_unit": 2
    }]
  }'
```

```json
{
  "success": true,
  "price": {
    "total_price": "186.45",
    "currency": "CAD",
    "shipping_price": 186.45
  }
}
```

- `success` — `true`, gdy cena została obliczona.
- `price.total_price` / `price.currency` — cena magazynowania za okres, z podatkiem.
- `promotion` — występuje tylko wtedy, gdy obowiązuje promocja.

**Weryfikacja:** `success` lub `result` ma wartość true i dostępna jest cena. Brak `warehouse_id` / dat skutkuje odpowiedzią `400`.

## 7. Utworzenie zamówienia magazynowego

Zamówienie magazynowe zapowiada magazynowi przychodzące paczki i ustala okres magazynowania. Integracja przechowuje zwrócony identyfikator; jest on potrzebny do płatności, odczytu zamówienia i jego anulowania.

**REST:** `POST /api/v1/customer/storage-orders` — [Podręcznik REST](/api/documentation#/paths/v1-customer-storage-orders/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/storage-orders \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: northwind-winter-2026-po-4471" \
  -d '{
    "warehouse_id": 7,
    "start_date": "2026-11-01",
    "end_date": "2027-03-31",
    "notes": "Winter 2026 stock, PO 4471",
    "items": [{
      "description": "Insulated jackets, carton of 12",
      "qty": 2,
      "length": 60,
      "width": 40,
      "height": 40,
      "dimension_unit": 2,
      "weight": 14,
      "weight_unit": 2,
      "value": 1800
    }]
  }'
```

```json
{
  "result": true,
  "message": "Storage order created",
  "data": { "id": 1024, "status": "pending payment" }
}
```

- `data.id` — identyfikator zamówienia magazynowego. Należy go zapisać razem z zamówieniem zakupu.
- `data.status` — `pending payment` do czasu opłacenia zamówienia.
- `Idempotency-Key` — należy go wyprowadzić z własnego stałego identyfikatora. Powtórzony klucz z tą samą treścią zwraca pierwszą odpowiedź (`replayed: true`); ten sam klucz z inną treścią jest odrzucany z `409 IDEMPOTENCY_CONFLICT`.
- Pola wymagane: `warehouse_id`, `start_date`, `end_date` (późniejsza niż `start_date`) oraz `items[]` z `qty`, `length`, `width`, `height`, `dimension_unit`. Gdy `allow_custom_package` ma wartość `false`, należy dodać `items[].packaging_id`.

**Weryfikacja:** odpowiedź zawiera `data.id`. Należy zapisać ten identyfikator zamówienia magazynowego.

## 8. Płatność za magazynowanie

Płatność potwierdza zamówienie magazynowe. Integracja może najpierw odczytać należną kwotę, a następnie zapłacić z salda klienta.

**REST:** `GET /api/v1/customer/storage-orders/{id}/payment-info` — [Podręcznik REST](/api/documentation#/paths/v1-customer-storage-orders-id--payment-info/get) (opcjonalnie)

```bash
curl https://YOUR_HOST/api/v1/customer/storage-orders/1024/payment-info \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "success": true,
  "result": true,
  "data": {
    "order_id": 1024,
    "currency": "CAD",
    "total_price": "186.45",
    "paid_amount": "0.00",
    "remaining_balance": "186.45",
    "user_balance": "500.00",
    "has_sufficient_balance": true,
    "minimum_payment": "186.45"
  }
}
```

**REST:** `POST /api/v1/customer/storage-orders/{id}/pay` — [Podręcznik REST](/api/documentation#/paths/v1-customer-storage-orders-id--pay/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/storage-orders/1024/pay \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"payment_type": "full_balance"}'
```

```json
{
  "success": true,
  "result": true,
  "message": "Payment of $186.45 processed successfully. Your storage order is now confirmed.",
  "new_balance": 313.55,
  "paid_amount": 186.45,
  "charge_amount": 186.45,
  "is_fully_paid": true
}
```

- `payment_type` — `full_balance` (domyślnie, opłaca pozostałą kwotę), `minimum_payment` (opłaca minimum wymagane przez firmę) lub `custom` razem z `custom_amount`.
- `is_fully_paid` — `true`, gdy nie pozostała żadna kwota do zapłaty.
- Przy niewystarczającym saldzie odpowiedzią jest `400` z `customer_balance`; należy doładować saldo i ponowić próbę.

Należy odczytać zamówienie, aby potwierdzić jego status, a później sprawdzić, które paczki magazyn przyjął.

**REST:** `GET /api/v1/customer/storage-orders/{id}` — [Podręcznik REST](/api/documentation#/paths/v1-customer-storage-orders-id/get)

```bash
curl https://YOUR_HOST/api/v1/customer/storage-orders/1024 \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "result": true,
  "data": {
    "id": 1024,
    "status": "confirmed",
    "can_cancel": true,
    "store_from": "2026-11-01",
    "store_to": "2027-03-31",
    "warehouse_id": 7,
    "packages": [
      { "id": 5001, "description": "Insulated jackets, carton of 12", "quantity": 1, "received": false },
      { "id": 5002, "description": "Insulated jackets, carton of 12", "quantity": 1, "received": false }
    ],
    "total_price": 186.45,
    "currency": "CAD"
  }
}
```

- `status` — `confirmed` po płatności; później `partial received` / `storage in progress` w miarę przyjmowania towaru.
- `packages[].received` — `true`, gdy magazyn przyjął daną paczkę.
- `can_cancel` — czy zamówienie magazynowe można jeszcze anulować.

**GraphQL:** `customerStorageOrderShow` ([Podręcznik GraphQL](/api/graphql/documentation#/customer/customerStorageOrderShow)); lista wszystkich zamówień magazynowych to `customerStorageOrders` ([Podręcznik GraphQL](/api/graphql/documentation#/customer/customerStorageOrders)).

```graphql
query {
  customerStorageOrderShow(id: 1024) {
    result
    data {
      id
      status
      can_cancel
      packages { id description received }
    }
  }
}
```

**Weryfikacja:** zamówienie magazynowe jest opłacone / potwierdzone. Odpowiedź `400` z `customer_balance` oznacza, że należy doładować saldo i ponowić próbę.

Opisane poniżej wydanie działa dopiero po **przyjęciu** paczek w magazynie. Podczas testu należy poczekać, aż personel (lub testowe przyjęcie) oznaczy je jako przyjęte, a następnie kontynuować.

## 9. Lista pozycji nadal na stanie

Ta lista to zapasy, które integracja może wysłać. Zawiera wyłącznie paczki przyjęte przez magazyn, które nie są już zablokowane dla innego wydania.

**REST:** `GET /api/v1/customer/shipout-orders/available-items` — [Podręcznik REST](/api/documentation#/paths/v1-customer-shipout-orders-available-items/get)

```bash
curl "https://YOUR_HOST/api/v1/customer/shipout-orders/available-items?warehouse_id=7" \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "result": true,
  "data": {
    "warehouses": [{ "id": 7, "name": "Toronto Hub", "available_count": 2 }],
    "storage_orders": [{
      "id": 1024,
      "warehouse_id": 7,
      "packages": [
        { "id": 5001, "description": "Insulated jackets, carton of 12", "weight": 14, "weight_unit": 2, "length": 60, "width": 40, "height": 40, "dimension_unit": 2, "value": 1800 },
        { "id": 5002, "description": "Insulated jackets, carton of 12", "weight": 14, "weight_unit": 2, "length": 60, "width": 40, "height": 40, "dimension_unit": 2, "value": 1800 }
      ]
    }]
  }
}
```

- `storage_orders[].packages[].id` — `storage_package_ids` do wydania w kroku 10.
- `warehouses[].available_count` — liczba dostępnych paczek w każdym magazynie.

**GraphQL:** `customerShipoutAvailableItems` ([Podręcznik GraphQL](/api/graphql/documentation#/storage-shipout/customerShipoutAvailableItems))

```graphql
query {
  customerShipoutAvailableItems(warehouse_id: 7) {
    result
    data {
      warehouses { id name available_count }
      storage_orders { id warehouse_id packages { id description weight length width height } }
    }
  }
}
```

**Weryfikacja:** zapisano jedną lub więcej wartości `storage_package_ids` (przykład: `5001`). Pusta lista oznacza, że nic nie zostało jeszcze przyjęte — nie należy tworzyć wydania. `403` oznacza, że wydanie jest wyłączone dla tego klienta.

## 10. Wycena i utworzenie wydania

Wydanie jest wyceniane przez jedną z usług wysyłkowych firmy. Integracja wyświetla listę usług dostępnych z magazynu, wycenia przesyłkę do miejsca docelowego nabywcy, a następnie tworzy wydanie dla wybranych paczek.

**REST:** `GET /api/v1/customer/shipout-orders/services?warehouse_id=7` — [Podręcznik REST](/api/documentation#/paths/v1-customer-shipout-orders-services/get)

```bash
curl "https://YOUR_HOST/api/v1/customer/shipout-orders/services?warehouse_id=7" \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "result": true,
  "data": {
    "services": [
      { "id": 5, "service_code": "intl_express", "name": { "en": "Express" }, "pricing_method": 1, "pricing_method_name": "Shipping Price Plan", "support_multi_package": true }
    ]
  }
}
```

Należy zapisać `service_code`.

**REST:** `POST /api/v1/customer/shipout-orders/services/{serviceCode}/estimate` — [Podręcznik REST](/api/documentation#/paths/v1-customer-shipout-orders-services-serviceCode--estimate/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipout-orders/services/intl_express/estimate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "warehouse_id": 7,
    "delivery_postcode": "K2P1L4",
    "delivery_country": "CA",
    "packages": [{
      "weight": 14,
      "length": 60,
      "width": 40,
      "height": 40,
      "weight_unit": 2,
      "dimension_unit": 2
    }]
  }'
```

```json
{
  "result": true,
  "data": {
    "shipping_fee": 38.5,
    "fuel_surcharge": 4.2,
    "handling_fee": 0,
    "sub_total": 42.7,
    "tax": 5.55,
    "total": 48.25,
    "currency": "CAD",
    "has_items_needing_quote": false,
    "refused": false
  }
}
```

- `total` / `currency` — szacunkowa cena dla tego miejsca docelowego.
- `has_items_needing_quote` — `true`, gdy usługa jest wyceniana ręcznie; magazyn ustala cenę po utworzeniu wydania, a płatność na nią oczekuje.
- `refused` / `refusal_message` — usługa nie przyjmie tej przesyłki, ponieważ nie może jej wycenić.

**REST:** `POST /api/v1/customer/shipout-orders/services/{serviceCode}/orders` — [Podręcznik REST](/api/documentation#/paths/v1-customer-shipout-orders-services-serviceCode--orders/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipout-orders/services/intl_express/orders \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: northwind-order-NW-20931" \
  -d '{
    "warehouse_id": 7,
    "storage_package_ids": [5001],
    "delivery_name": "Maya Chen",
    "delivery_telephone": "6135550142",
    "delivery_email": "maya.chen@example.com",
    "delivery_address_1": "150 Elgin St",
    "delivery_city": "Ottawa",
    "delivery_province": "ON",
    "delivery_country": "CA",
    "delivery_postcode": "K2P1L4",
    "note": "Web order NW-20931"
  }'
```

```json
{
  "result": true,
  "data": {
    "id": 8001,
    "status": 0,
    "is_storage_shipout": true,
    "total_price": "48.25",
    "price_breakdown": { "total": 48.25, "has_items_needing_quote": false },
    "has_items_needing_quote": false,
    "storage_package_ids": [5001]
  }
}
```

- `data.id` — identyfikator wydania. Należy go zapisać razem z zamówieniem nabywcy.
- `data.status` — `0` = oczekujące (czeka na płatność), `1` = potwierdzone, `2` = w transporcie, `3` = wysłane, `4` = anulowane, `5` = nieudane.
- `storage_package_ids` — te paczki są teraz zablokowane dla tego wydania i nie pojawiają się już w kroku 9.
- Pola wymagane: `warehouse_id`, `storage_package_ids`, `delivery_name`, `delivery_telephone`, `delivery_address_1`, `delivery_city`, `delivery_province`, `delivery_country`, `delivery_postcode`. Wszystkie paczki muszą pochodzić z tego samego magazynu.

**GraphQL:** `customerCreateShipoutOrder` ([Podręcznik GraphQL](/api/graphql/documentation#/customer/customerCreateShipoutOrder))

```graphql
mutation {
  customerCreateShipoutOrder(
    service_code: "intl_express"
    warehouse_id: 7
    storage_package_ids: [5001]
    delivery_name: "Maya Chen"
    delivery_telephone: "6135550142"
    delivery_email: "maya.chen@example.com"
    delivery_address_1: "150 Elgin St"
    delivery_city: "Ottawa"
    delivery_province: "ON"
    delivery_country: "CA"
    delivery_postcode: "K2P1L4"
    note: "Web order NW-20931"
  ) {
    result
    message
    data { id status total_price has_items_needing_quote storage_package_ids }
  }
}
```

**Weryfikacja:** odpowiedź zawiera `id` wydania. Wybrane paczki magazynowe są zablokowane dla tego zlecenia.

## 11. Płatność za wydanie

Magazyn realizuje wydanie po jego opłaceniu. Integracja opłaca pozostałą kwotę z konta klienta; aby zapłacić całość, należy pominąć `amount`.

**REST:** `POST /api/v1/customer/shipout-orders/{id}/pay` — [Podręcznik REST](/api/documentation#/paths/v1-customer-shipout-orders-id--pay/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipout-orders/8001/pay \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

```json
{
  "result": true,
  "data": {
    "transaction_id": 9001,
    "amount": "48.25",
    "order_status": 1,
    "paid_amount": 48.25,
    "remaining_balance": 0
  }
}
```

- `amount` (żądanie, opcjonalnie) — kwota częściowa; domyślnie cała pozostała kwota.
- `order_status` — `1` (potwierdzone) po pełnej płatności.
- `remaining_balance` — `0` po pełnej płatności.

**GraphQL:** `customerPayShipout` ([Podręcznik GraphQL](/api/graphql/documentation#/storage-shipout/customerPayShipout))

```graphql
mutation {
  customerPayShipout(id: 8001) {
    result
    message
    data { transaction_id amount order_status paid_amount remaining_balance }
  }
}
```

**Weryfikacja:** płatność rejestruje kwotę (albo zwraca `402` / `422` z jednoznaczną przyczyną). `402` oznacza niewystarczające saldo; `422` oznacza, że zamówienia nie można jeszcze opłacić (na przykład nadal oczekuje na ręczną wycenę) lub kwota jest nieprawidłowa.

## 12. Odczyt i śledzenie wydania

Integracja odczytuje wydanie, aby śledzić jego status, a po wysłaniu przez magazyn śledzi przesyłkę według numeru śledzenia.

**REST:** `GET /api/v1/customer/shipout-orders/{id}` — [Podręcznik REST](/api/documentation#/paths/v1-customer-shipout-orders-id/get)

```bash
curl https://YOUR_HOST/api/v1/customer/shipout-orders/8001 \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "result": true,
  "data": {
    "id": 8001,
    "status": 3,
    "status_label": "Shipped",
    "warehouse": { "id": 7, "name": "Toronto Hub" },
    "shipping_service": { "id": 5, "service_code": "intl_express" },
    "total_price": "48.25",
    "paid_amount": 48.25,
    "remaining_balance": 0,
    "can_be_paid": false,
    "can_be_cancelled": false,
    "storage_packages": [{ "id": 5001, "storage_order_id": 1024, "description": "Insulated jackets, carton of 12" }]
  }
}
```

- `status` / `status_label` — bieżący stan wydania.
- `can_be_paid` / `can_be_cancelled` — czy krok 11 lub krok 14 jest obecnie dozwolony.

Gdy istnieje numer śledzenia:

**REST:** `GET /api/v1/tracking/{trackingNumber}` — [Podręcznik REST](/api/documentation#/paths/v1-tracking-trackingNumber/get)

```bash
curl https://YOUR_HOST/api/v1/tracking/SR123456789012
```

```json
{
  "result": true,
  "deliveried": false,
  "data": [
    { "tracking_event_status_id": 3, "description": "Package picked up", "location_city": "Toronto", "updated_at": "2026-12-02 14:30:00" }
  ]
}
```

- `data[]` — zdarzenia śledzenia w kolejności chronologicznej.
- `deliveried` — `true` po doręczeniu przesyłki.

**GraphQL:** `trackingPublic` ([Podręcznik GraphQL](/api/graphql/documentation#/tracking/trackingPublic))

```graphql
query {
  trackingPublic(trackingNumber: "SR123456789012") {
    result
    deliveried
    data { tracking_event_status_id description location_city updated_at }
  }
}
```

**Weryfikacja:** odczyt wydania zwraca oczekiwany `status`. Publiczne śledzenie znajduje przesyłkę, gdy istnieje numer śledzenia.

## 13. Subskrypcja webhooków

Webhooki przesyłają zmiany śledzenia i statusu na serwer integracji, bez konieczności cyklicznego odpytywania. Konto klienta samo ustawia adresy URL webhooków i sekret podpisu; ustawienia są przechowywane na koncie klienta, a nie na koncie firmy.

**REST:** `PUT /api/v1/webhook-settings` — [Podręcznik REST](/api/documentation#/paths/v1-webhook-settings/put)

```bash
curl -X PUT https://YOUR_HOST/api/v1/webhook-settings \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "tracking_event_webhook_url": "https://hooks.northwind-outdoor.example/tracking",
    "order_status_change_webhook_url": "https://hooks.northwind-outdoor.example/status",
    "webhook_sign_secret": "nw-webhook-secret-2026-0123456789"
  }'
```

```json
{
  "result": true,
  "changed_keys": ["tracking_event_webhook_url", "order_status_change_webhook_url", "webhook_sign_secret"],
  "recipient_type": "customer",
  "settings": {
    "webhook_sign_secret": "************6789",
    "tracking_event_webhook_url": "https://hooks.northwind-outdoor.example/tracking",
    "order_status_change_webhook_url": "https://hooks.northwind-outdoor.example/status"
  }
}
```

- Zmieniają się tylko przesłane klucze; nieznany klucz lub nieprawidłowy URL skutkuje odpowiedzią `400`.
- `recipient_type` — wartość `customer` potwierdza, że ustawienia należą do konta klienta.
- `webhook_sign_secret` — od 16 do 255 znaków; należy go przechowywać na serwerze w celu weryfikacji podpisów.

**GraphQL:** `webhookSettingsUpdate` ([Podręcznik GraphQL](/api/graphql/documentation#/webhooks/webhookSettingsUpdate)).

Należy weryfikować podpis **v2**: `HMAC_SHA256(timestamp + "." + raw_body, secret)` względem `X-Webhook-Signature-V2`. Duplikaty należy odrzucać według `X-Webhook-Event-Id`. Odpowiedź **2xx należy zwrócić w czasie krótszym niż 3 sekundy**.

```php
$raw = file_get_contents('php://input');
$ts  = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE_V2'] ?? '';
$expected = hash_hmac('sha256', $ts . '.' . $raw, $sharedSecret);
if (!hash_equals($expected, $sig)) {
    http_response_code(401);
    exit;
}
```

**Weryfikacja:** aktualizacja zwraca `changed_keys` z przesłanymi kluczami, a zdarzenie testowe odebrane pod adresem URL przechodzi powyższą weryfikację podpisu.

## 14. Anulowanie wydania lub zamówienia magazynowego

Anulowanie zwalnia to, co zostało zarezerwowane. Anulowanie wydania przywraca jego paczki do zapasów; anulowanie zamówienia magazynowego wstrzymuje rezerwację, której towar nie został jeszcze przyjęty.

**REST:** `POST /api/v1/customer/shipout-orders/{id}/cancel` — [Podręcznik REST](/api/documentation#/paths/v1-customer-shipout-orders-id--cancel/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipout-orders/8001/cancel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"reason": "Buyer cancelled web order NW-20931"}'
```

```json
{ "result": true, "message": "Shipout cancelled." }
```

- `reason` (opcjonalnie) — zapisywany wraz z anulowaniem.
- Wydanie można anulować tylko wtedy, gdy jest oczekujące (`0`) lub potwierdzone (`1`).

**GraphQL:** `customerCancelShipout` ([Podręcznik GraphQL](/api/graphql/documentation#/storage-shipout/customerCancelShipout))

```graphql
mutation {
  customerCancelShipout(id: 8001, reason: "Buyer cancelled web order NW-20931") {
    result
    message
  }
}
```

Zwalnia to blokadę paczek magazynowych. Samo magazynowanie anuluje się przez `POST /api/v1/customer/storage-orders/{id}/cancel`, dopóki jest to jeszcze dozwolone (status `pending payment`, `confirmed`, `waiting for pickup` lub `awaiting dropoff`; [Podręcznik REST](/api/documentation#/paths/v1-customer-storage-orders-id--cancel/post)).

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/storage-orders/1024/cancel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

```json
{ "result": true, "message": "Storage order cancelled", "data": { "id": 1024, "status": "cancelled" } }
```

- Kwota już zapłacona za zamówienie magazynowe jest zwracana na saldo klienta.
- Zamówienie magazynowe w każdym innym statusie zwraca `403`.

**Weryfikacja:** `422` oznacza, że tego statusu nie można anulować. Po udanym anulowaniu wydania krok 9 ponownie wyświetla paczki.

## 15. Obsługa błędów

| Sytuacja | Status HTTP | Kod | Działanie integracji |
|---|---|---|---|
| Brak tokenu, token wygasł lub nieprawidłowy typ konta | 401 | — | Zalogować się ponownie jako klient (krok 4). |
| Wycena magazynowania bez `warehouse_id` lub dat | 400 | — | Przesłać `warehouse_id`, `start_date` i `end_date`. |
| Walidacja zamówienia magazynowego nie powiodła się (brak wymiarów pozycji, `end_date` nie jest późniejsza niż `start_date`, brak `packaging_id`) | 422 | — | Odczytać `errors`, poprawić pola i wysłać ponownie. |
| Płatność za magazynowanie przy niewystarczającym saldzie lub zamówienie już w pełni opłacone | 400 | — | Doładować saldo (odpowiedź zawiera `customer_balance`) lub zakończyć, jeśli zamówienie jest już opłacone. |
| Płatność za magazynowanie z `custom_amount` spoza dozwolonego zakresu | 422 | — | Zapłacić kwotę między minimum a pozostałą kwotą. |
| Zamówienia magazynowego nie można anulować w bieżącym statusie | 403 | — | Poprosić magazyn o obsługę zamówienia; nie ponawiać. |
| Wydanie wyłączone dla tego klienta | 403 | — | Poprosić firmę o włączenie wydania dla konta klienta. |
| Nieznany kod usługi lub nie znaleziono wydania / zamówienia magazynowego | 404 | — | Ponownie odczytać listę usług lub sprawdzić zapisany identyfikator. |
| Paczka niedostępna, paczki z różnych magazynów lub usługa niedostępna z danego magazynu | 422 | — | Ponownie odczytać krok 9 i wybrać dostępne paczki z jednego magazynu. |
| Usługa wysyłkowa nie może wycenić przesyłki i ją odrzuca | 422 | `unpriced_refused` | Wybrać inną usługę lub miejsce docelowe; nic nie zostało utworzone. |
| Płatność za wydanie przy niewystarczającym saldzie | 402 | — | Doładować saldo, a następnie ponowić krok 11. |
| Wydania nie można jeszcze opłacić (oczekuje na ręczną wycenę) lub kwota jest nieprawidłowa | 422 | — | Poczekać na cenę, ponownie odczytać wydanie, a następnie zapłacić. |
| Wydania nie można anulować w bieżącym statusie | 422 | — | Przesyłka jest już w realizacji; nie ponawiać. |
| Ten sam `Idempotency-Key` wysłany z inną treścią | 409 | `IDEMPOTENCY_CONFLICT` | Użyć nowego klucza dla innego żądania. |
| Pierwotne żądanie z tym samym `Idempotency-Key` jest nadal przetwarzane | 409 | `IDEMPOTENCY_IN_PROGRESS` | Odczekać `Retry-After` sekund i ponownie wysłać to samo żądanie. |

## Lista testów

- [ ] Konfiguracja magazynowania zwraca `id` magazynu.
- [ ] Wycena magazynowania zwraca cenę, a utworzenie magazynowania zwraca `data.id`.
- [ ] Płatność za magazynowanie kończy się powodzeniem **albo** potwierdzono, że portfel trzeba doładować.
- [ ] Lista dostępnych pozycji pokazuje przyjęte paczki (`storage_package_ids`).
- [ ] Wycena wydania zwraca cenę lub `has_items_needing_quote`, a utworzenie wydania zwraca `id` i blokuje te paczki.
- [ ] Płatność za wydanie kończy się powodzeniem (albo odpowiedź `402` / `422` jest zrozumiała).
- [ ] Publiczne śledzenie znajduje przesyłkę, gdy istnieje numer śledzenia.
- [ ] Anulowanie wydania zwalnia paczki **albo** tego statusu nie można anulować.
- [ ] Powtórzenie utworzenia z tym samym `Idempotency-Key` i tą samą treścią zwraca `replayed: true` i nie tworzy drugiego zamówienia.
- [ ] Ustawienia webhooków zwracają `recipient_type: customer`, a odebrane zdarzenie przechodzi weryfikację podpisu v2.
