Wysyłki
OficjalnyDarmowyCentrum przesyłek: rejestr paczek, etykiety, statusy i podpowiedź przewoźnika dla zamówienia. Potrzebny dla modułów przewoźników (DPD, InPost…).
- Licencja
- Bezpłatny
- Wersja
- 2.21.21
- Kategoria
- Wysyłka
Moduł przesyłek (core/shipping)
Hub przesyłek: moduły przewoźników (np. carrier.dpd) rejestrują się hookiem core.shipping.getCarrier, a ten moduł prowadzi centralny rejestr przesyłek, warunki doboru przewoźnika dla zamówienia, etykiety oraz cron odświeżający statusy.
Tożsamością przewoźnika jest instanceId, więc jedna firma może mieć kilka kont tego samego przewoźnika. Wywołania przewoźników korzystają z kontraktu hooków shipping.carrier i wskazanej instancji przez lokalny requestProvider. Izolację danych per firma wymusza rdzeń.
Kolejna przesyłka po zakończonym nadaniu (2.21.21)
Jeśli zamówienie ma już nadany plan opakowań, wybierz Dodaj paczkę → Przygotuj kolejną przesyłkę. Ta akcja zachowuje historię poprzedniego wyboru i listów, a następnie udostępnia ponownie wybór kartonu, kafelki przewoźników oraz pozycje Nadaj z … w menu Dodaj paczkę. Dopiero wybór oferty i nadanie tworzą nową przesyłkę. Dotychczasowe etykiety pozostają w rejestrze.
Przygotowanie nowej przesyłki wymaga potwierdzonego zapisu poprzednich listów w tym rejestrze. Niepewne i trwające nadania nadal blokują kolejną próbę. Nowy wybór otrzymuje osobny token, dlatego stara oferta ani ponowione kliknięcie przygotowania nie mogą uruchomić następnego nadania. Zmienione zamówienie lub katalog opakowań wymagają ponownego doboru aktualnego kartonu.
Moduł wymaga SDK @orbito/api-lib w wersji ≥ 4.4.1: od 3.3.2 SDK poprawnie rzutuje zapytania z operatorami Mongo ($in, $regex), na których opierają się filtry rejestru przesyłek, 3.5.0 dokłada Core.getUser(userId) — bez niego widoki pokazałyby w kolumnie „Pracownik” surowe createdBy — 3.10.0 History.add, czyli wpisy hubu w historii firmy (Ustawienia → Historia), 4.0.0 prefiksy kluczy zdarzeń automatyzacji i Templates.registerVarGroup (zmienne szablonów wiadomości), a 4.1.0 Docs.register — dokumenty wiedzy z katalogu ai-docs/ dla asystenta AI firmy (src/ai-docs.js). Od 4.4.0 SDK obsługuje TLS kanału RPC (ORBITO_TLS_CA) — bez niego moduł nie połączy się z rdzeniem uruchamiającym moduły w klastrze Kubernetes. Poza SDK moduł używa jszip (archiwum etykiet w shipments.getLabelsArchive), cron (odświeżanie statusów), fastest-validator (kontrakty przewoźników) i js-yaml (schematy OVE).
Hooks
Emitowany: core.shipping.getCarrier
Moduł przewoźnika, który chce być uwzględniany przy nadawaniu, odpowiada na ten hook definicją (walidator checkCarrier w src/validators.js). Definicje niezgodne z kontraktem są odrzucane i logowane (Log.warn('Invalid carrier definition')) — hub nie rzuca błędem.
{
name: 'string', // przyjazna nazwa, np. "DPD — konto główne"
moduleCode: 'string', // Core.getModuleInfo().code
instanceId: 'string', // Core.getInstanceId() — TOŻSAMOŚĆ przewoźnika w hubie
logoFile: 'string|optional', // nazwa logo w buckecie TEJ instancji, np. "logo.jpg"
quickShipOptionsMethod: 'string|optional', // ({ orderId, ...carrierOptions }) → QuickShipOption[]
quickShipParamsMethod: 'string|optional', // () → schema opcji dla warunków (nieużywane w tej iteracji)
getTrackingStatusMethod: 'string|optional', // ({ trackNo, carrierData }) → TrackingStatus
generateLabelsMethod: 'string|optional', // ({ shipments: [{ _id, trackNo, carrierData }] })
// → LabelFile | LabelFile[] | { labels: LabelFile[] }
shipmentFormMethod: 'string|optional' // ({ orderId, ...carrierOptions }) → { view } | { error }
}
logoFile niesie samą nazwę pliku, nigdy bajtów. Logo leży w buckecie plików instancji przewoźnika — każda instalacja modułu ma własny, odseparowany magazyn, kasowany razem z instancją. Przewoźnik wrzuca tam plik raz, przy starcie, przez Files.upload z SDK ≥ 3.6.0, a widok pobiera go osobnym żądaniem po instanceId i nazwie. Hub nie zna marek przewoźników i nie przenosi obrazów; gdy logoFile nie ma, widok pokazuje inicjały nazwy przewoźnika.
Nazwa musi przejść reguły nazw bucketu z rdzenia (znaki [a-zA-Z0-9._/-], maks. 255 znaków, bez wiodącego /, bez ..) i mieć rozszerzenie graficzne (png, jpg, jpeg, gif, webp, svg, ico, bmp, avif) — plik bez rozpoznanego typu MIME nie wyrenderuje się w widoku. Definicja z niepoprawną nazwą jest odrzucana jak każda inna niezgodna z kontraktem.
Rejestracja po stronie modułu przewoźnika:
// Upload jest idempotentny: SDK liczy sha256 lokalnie, więc to samo logo wypychane
// przy każdym starcie przenosi się raz. Błąd uploadu nie może wywalić startu modułu.
try {
await Files.upload('./assets/logo.jpg', { name: 'logo.jpg' });
} catch (err) {
Log.warn('Nie udało się wgrać logo przewoźnika', { error: err?.message });
}
Core.registerHook('core.shipping.getCarrier', () => ({
name: 'DPD — konto główne',
moduleCode: Core.getModuleInfo().code,
instanceId: Core.getInstanceId(),
logoFile: 'logo.jpg',
quickShipOptionsMethod: 'getQuickShipOptions',
shipmentFormMethod: 'getShipmentForm',
getTrackingStatusMethod: 'getTrackingStatus',
generateLabelsMethod: 'generateLabelsBatch'
}));
Kontrakty odpowiedzi przewoźnika
ShipmentFormView — odpowiedź shipmentFormMethod. Hub nie zna pól żadnego przewoźnika: sprawdza tylko, czy view jest obiektem ze stringiem w polu type (tak samo jak przy fragmentach views.orderDetails), a resztę schematu waliduje schemaGuard po stronie przeglądarki. Odpowiedź niezgodna z kontraktem albo błąd wywołania (try/catch w methods/getShipmentForm.js) kończy się widokiem tekstowym z komunikatem, a nie awarią karty:
{ view: { type: 'flex', ... } } // schemat OVE formularza (pola + przycisk „Nadaj”)
{ error: 'string' } // powód, dla którego formularza nie da się zbudować
Formularz przewoźnika zna swój instanceId (Core.getInstanceId()) i dostaje orderId w parametrach wywołania, więc sam wstawia je do przycisku „Nadaj”, który woła router nadania huba (core/shipping → ship) z nazwą własnej metody nadającej (np. createShipment):
onClick:
action: callMethod
module: core/shipping
method: ship
params:
orderId: '<orderId>'
instanceId: '<instanceId>'
method: 'createShipment' # metoda nadająca w module przewoźnika
withData:
- { from: inpWeight, as: 'params.parcel.weight' }
- { from: inpCod, as: 'params.cod.amount' }
onSuccess:
action: run
name: shipmentCreated
onError:
action: run
name: shipmentFailed
args:
message: '{{ $error.message }}'
Formularz to obcy schemat zamontowany wewnątrz modala karty przesyłek, więc nie może znać jej identyfikatorów widoków — komunikuje wynik nadania wyłącznie nazwanymi akcjami, zadeklarowanymi w actionDefinitions karty (pages/slots/order-details.yaml). findActionDefinition szuka definicji idąc w górę drzewa widoków, aż trafi na kartę:
| Nazwa | Co robi |
|---|---|
shipmentCreated | zamyka modal formularza, chowa kafelki szybkiego nadania, przeładowuje tabelę przesyłek, toast sukcesu |
shipmentFailed | toast błędu z treścią {{ $args.message }} (argument, który formularz podał w action: run) |
QuickShipOption — pojedyncza opcja z quickShipOptionsMethod. Hub dodatkowo sprawdza, czy instanceId opcji jest dostępny dla zamówienia (inaczej nadanie omijałoby warunki doboru):
{
title: 'string',
iconSrc: 'string|optional', // kontrakt go przyjmuje, ale kafelek pokazuje logo z bucketu
parcels: 'string[]', // opis paczek, np. ["30 x 20 x 10 cm — 2.5 kg"]
isCod: 'boolean|optional',
instanceId: 'string', // instancja przewoźnika, która obsłuży nadanie
method: 'string', // metoda nadająca w tej instancji
params: 'object|optional' // parametry dokładane do wywołania metody nadającej
}
CreatedShipment — wynik metody nadającej, na jego podstawie powstaje wpis w rejestrze. Metoda nadająca dostaje { orderId, ...carrierOptions (z dopasowanego warunku), ...option.params }:
{
carrier: 'string',
trackNo: 'string',
trackingUrl: 'string|optional',
status: 'enum|optional', // wartość z SHIPMENT_STATUSES, domyślnie 'sent'
parcels: 'array|optional',
codAmount: 'number|optional|convert',
carrierData: 'object|optional', // dane specyficzne przewoźnika (np. reference/sessionId)
label: 'object|optional' // LabelFile, jeśli przewoźnik zwraca etykietę od razu
}
LabelFile — plik etykiety:
{
contentBase64: 'string',
mimeType: 'string',
filename: 'string|optional',
shipmentId: 'string|optional'
}
shipmentId jest wymagany, gdy przewoźnik zwraca etykiety dla batcha — hub musi wiedzieć, do której przesyłki należy plik. Wyjątkiem jest żądanie o jedną etykietę dla jednej przesyłki: wtedy hub przypisuje ją sam. Etykiety bez pasującej przesyłki są odrzucane i logowane, ale nie przerywają batcha.
TrackingStatus — wynik getTrackingStatusMethod:
{
status: 'enum', // wartość z SHIPMENT_STATUSES
statusDate: 'date|optional|convert',
carrierStatus: 'string|optional' // surowy status przewoźnika (do podglądu)
}
Konsumowany: views.orderDetails
Zależność ze stroną zamówienia jest jednostronna — core.orders nie zna tego modułu. Emitentem hooka jest core.orders, a ten moduł rejestruje na niego handler (src/hooks/order-details-view.js, podpięty w index.js przez Core.registerHook) zwracający własny fragment widoku OVE. Emitent skleja fragmenty wszystkich modułów. Kontrakt hooka jest własnością core.orders — poniżej wersja, którą ten moduł implementuje:
// argument (od core.orders)
{ orderId: 'string', order: 'object' } // order — surowy dokument zamówienia
// odpowiedź tego modułu (brak fragmentu → null)
{
key: 'core.shipping', // stabilny identyfikator fragmentu
title: 'Przesyłki',
order: 20, // podpowiedź kolejności sklejania
view: { ... } // schemat OVE z pages/slots/order-details.yaml, z podstawionym @orderId
}
Emitent odrzuca fragmenty bez key (string) i bez view.type (string), deduplikuje po key (wygrywa pierwszy) i sortuje rosnąco po order (brak wartości → 100).
Handler nie sprawdza uprawnień (robi to emitent) — uprawnienia egzekwują metody wołane z widoku. Protokół hooka nie ma kanału błędów (wyjątek daje pustą odpowiedź), więc handler owija wszystko w try/catch, loguje i zwraca null.
Konsumowany: orders.flags
Flaga „Przesyłka” w wierszu listy zamówień. Jak wyżej — kontrakt jest własnością core.orders, który emituje hook raz na stronę wyników search (≤ 100 zamówień), a ten moduł odpowiada opisem własnej flagi (src/hooks/order-flags.js, podpięty w index.js).
// argument (od core.orders) — lekka projekcja, żeby handler nie wołał z powrotem core/orders
{ orderIds: ['<id>', ...], orders: [{ _id, invoice: { isNeeded }, isPaid, isPacked, delivery: { isCod } }] }
// odpowiedź tego modułu (brak identyfikatorów albo błąd → null)
{
key: 'shipment',
label: 'Przesyłka',
icon: 'truck-delivery', // ikona Tabler bez prefiksu `ti-`
order: 30, // kolejność w wierszu; flagi natywne mają 10, 20, 40, 50
inactive: { title: 'Brak przesyłki', summary: 'Nie utworzono przesyłki dla zamówienia' },
states: {
'<orderId>': {
active: true,
tone: 'success', // `warning`, gdy któraś przesyłka ma status `error` lub `delivery-problems`
title: 'Przesyłka utworzona', // kilka przesyłek → „Przesyłki: N”
summary: 'Kurier: DPD\nNumer przesyłki: 0001234567\nStatus: Nadana',
listItems: [{
label: 'DPD Kurier',
title: 'Śledź przesyłkę 0001234567',
url: 'https://tracking.example/0001234567',
image: { instanceId: '<carrier-instance-id>', name: 'logo.jpg', alt: 'DPD Kurier' },
}],
},
},
}
Szczegóły flagi są eager — emitent dostaje title/summary w tej samej odpowiedzi i nie wraca po nie osobnym wywołaniem. Cała flaga kosztuje jedno zapytanie Shipment.find({ orderId: { $in } }) (limit 1000, sortowanie createdAt: -1), a wyniki są grupowane po orderId. Lista identyfikatorów jest odchudzana po stronie handlera: bez pustych i nie-stringów, bez duplikatów, przycięta do 100. Zamówienia bez przesyłki nie dostają wpisu w states — emitent renderuje im wtedy inactive. Wartości w summary są unikalne i sklejane przecinkiem, linie bez danych są pomijane, a etykiety statusów idą z src/lib/shipment-status.js (STATUS_LABELS).
listItems są budowane z danych przesyłki oraz definicji przewoźnika zwracanej przez core.shipping.getCarrier. Pole image zawiera referencję do pliku w API (instanceId + name), nigdy base64; url jest zapisanym przez przewoźnika adresem śledzenia. Dzięki temu core.orders renderuje neutralny kontrakt i nie zna ani tego modułu, ani konkretnych przewoźników.
Eventy emitowane przez hub
Emisja idzie hookiem event:core.shipping.* (src/events.js); błąd emisji jest tylko logowany.
event:core.shipping.shipment.created—{ shipmentId, orderId, instanceId, moduleCode, carrier, trackNo, createdBy }event:shipment:created— alias zgodności dla historycznych integracji; zawiera także_id,createdByihasLabel(automatyzacje nowych przesyłek powinny używać natywnego triggera Shipping poniżej)- natywny trigger automatyzacji
core/shipping:shipment_created(„Nowa przesyłka (Shipping)”) — uruchamiany lokalnie po zapisaniu przesyłki i etykiety; emitujecore/orders:ORDER_ID,SHIPMENT_IDiUSER_ID - natywny trigger automatyzacji
core/shipping:shipment_status_changed(„Zmiana statusu przesyłki u kuriera”) — uruchamiany lokalnie po zapisanej zmianie kanonicznego statusu z poprawnej odpowiedzi przewoźnika; emitujecore/orders:ORDER_ID,SHIPMENT_ID,SHIPMENT_STATUSiPREVIOUS_SHIPMENT_STATUS event:core.shipping.shipment.status_changed—{ shipmentId, orderId, trackNo, oldStatus, newStatus }event:core.shipping.delivery_condition.created/.updated/.removed— dokument warunku
Historia firmy (Ustawienia → Historia)
Eventy budzą inne moduły, historia jest dla użytkownika: to niezmienny zapis tego, co stało się z przesyłką. Wpisy dopisuje src/history.js (logShipmentEvent) przez History.add z SDK — zawsze po udanym zapisie w bazie i nigdy nie przerywając operacji, która je wywołała (nieudany zapis historii ląduje w logu jako ostrzeżenie).
Każdy wpis niesie dwie relacje — przesyłki i zamówienia — więc widać go zarówno w historii przesyłki, jak i zamówienia:
[{ module: 'core/shipping', object: 'shipment', id: <_id przesyłki> },
{ module: 'core/orders', object: 'order', id: <orderId> }]
Relacja do zamówienia wystarcza, by wpis pojawił się w modalu historii w szczegółach zamówienia — core/orders czyta wspólną historię rdzenia po tej relacji. Od wersji 2.8.1 moduł nie przekazuje już kopii do core/orders:history.record.
| Kod zdarzenia | Kiedy | Opis (pl) |
|---|---|---|
SHIPMENT_CREATED | zapis przesyłki w rejestrze (ship, shipments.register, shipments.createManual) | Utworzono przesyłkę <nr> (<przewoźnik>) |
SHIPMENT_STATUS_CHANGED | faktyczna zmiana statusu (metody i cron statusów) | Status przesyłki <nr> zmieniony z „A” na „B” |
SHIPMENT_LABEL_PRINTED | przyjęte zlecenie wydruku (shipments.printLabel) | Wydrukowano etykietę przesyłki <nr> (<dostawca wydruku>) |
Opisy zapisujemy mapą { pl, en } (etykiety statusów z src/lib/shipment-status.js), bo wpis jest niezmienny — front wybiera język oglądającego przy odczycie. Autora bierzemy z kontekstu wywołania (ctx.userId); przebiegi cronu i automatyzacji nie mają kontekstu, więc zostają w historii zdarzeniami systemowymi.
Automatyzacje
Hub jest właścicielem słownika statusów przesyłek (src/lib/shipment-status.js), więc akcje operujące na przesyłkach należą do niego, a nie do modułu zamówień (src/automations.js, rejestracja w index.js).
Wyzwalacz Zmiana statusu przesyłki u kuriera (core/shipping:shipment_status_changed) reaguje na zapisane zmiany z automatycznego sprawdzania i obu metod odświeżania statusu. Opcjonalny parametr statuses jest tablicą kodów z SHIPMENT_STATUSES; brak, null lub pusta tablica oznacza wszystkie zmiany, wybrane wartości filtrują nowy status. Niepoprawna tablica jest błędem konfiguracji. Przykład: statuses: ['delivered'] rozpoczyna bieg po doręczeniu przesyłki.
Wyzwalacz przekazuje core/orders:ORDER_ID, SHIPMENT_ID, SHIPMENT_STATUS, PREVIOUS_SHIPMENT_STATUS oraz trackNo, carrier, instanceId, moduleCode, carrierStatus, statusDate i eventId. Zapis statusu jest atomowy względem odczytanej wersji przesyłki; nieaktualne równoległe odświeżenie nie powiela tej samej zmiany. statusEventId w rejestrze przechowuje tożsamość ostatniej zmiany statusu. Biegi są uruchamiane lokalnie po zapisie i obsłudze historii, bez oczekiwania na powrót hooka zdarzenia. Błąd pojedynczej reguły jest logowany, pozostałe reguły mogą się uruchomić, a niepewny wynik nie jest ponawiany.
Ręczny shipments.setStatus, lokalny untracked po 7 dniach, utworzenie przesyłki i zmiana samego tekstu carrierStatus.status nie uruchamiają tego wyzwalacza. Nie uruchamia go również brak dostawcy, niepoprawna odpowiedź przewoźnika ani niezapisana zmiana.
| Akcja | Parametry | Opis |
|---|---|---|
check_shipment („Dodano przesyłkę”) | — | Od razu wybiera true („Tak”) lub false („Nie”), zależnie od istnienia przesyłki zamówienia |
check_all_shipments_delivered („Wszystkie paczki dostarczone”) | — | Wybiera true („Tak”), gdy zamówienie ma co najmniej jedną nieukrytą przesyłkę i wszystkie mają status delivered; w pozostałych przypadkach false („Nie”) |
wait_for_shipment („Czekaj na przesyłkę”) | — | Wstrzymuje bieg, dopóki do zamówienia nie powstanie przesyłka |
wait_for_shipment_status („Czekaj na status przesyłki”) | expectedStatus (wymagany, lista z SHIPMENT_STATUSES + STATUS_LABELS) | Wstrzymuje bieg, dopóki co najmniej jedna przesyłka zamówienia nie osiągnie wybranego statusu |
print_order_labels („Wydrukuj etykiety zamówienia”) | copies (opcjonalny) | Drukuje przesyłkę z SHIPMENT_ID wyzwalacza lub wszystkie etykiety zamówienia przez aktywny moduł drukowania |
Obie akcje czekające wymagają core/orders:ORDER_ID i pauzują biegiem przez Automation.waitAndRetry(30), który uruchamia całe execute od nowa — handlery są więc czystymi warunkami, bez efektów ubocznych. Rdzeń nie waliduje parametrów akcji, dlatego expectedStatus spoza SHIPMENT_STATUSES kończy bieg błędem zamiast zostawiać go w nieskończonym oczekiwaniu.
Warunek check_shipment wymaga core/orders:ORDER_ID z wyzwalacza. Uwzględnia każdą przesyłkę w centralnym rejestrze, również ręczną i anulowaną: odpowiada na pytanie, czy przesyłkę dodano, a nie czy nadal jest aktywna. Nie wstrzymuje biegu i nie wymaga etykiety. Do uruchamiania automatyzacji w chwili dodania przesyłki służy istniejący wyzwalacz shipment_created.
Warunek Wszystkie paczki dostarczone sprawdza cały rejestr dla core/orders:ORDER_ID z biegu, także gdy wyzwalacz dotyczy jednej paczki. Pomija ukryte przesyłki; anulowane i zwrócone dają „Nie”. Czyta zapisane statusy bez odpytywania kuriera. Aby zmienić status zamówienia po doręczeniu ostatniej paczki, po wyzwalaczu shipment_status_changed z statuses: ['delivered'] dodaj ten warunek i podepnij zmianę statusu do „Tak”.
Akcja wait_for_shipment_status była wcześniej rejestrowana przez core.orders (z własnym, rozjeżdżającym się słownikiem statusów). Kod akcji jest ten sam, ale zmienił się jej właściciel — istniejące automatyzacje trzeba po wdrożeniu przepiąć ręcznie na akcję modułu core/shipping.
Identyfikator zamówienia należy do core/orders, więc od SDK 4.0.0 wszystkie akcje hubu deklarują requires: ['core/orders:ORDER_ID'] i czytają go wyłącznie z ctx.event.params['core/orders:ORDER_ID'] (src/event-keys.js). Klucze obce nie mają krótkich aliasów, a własne _id/orderId z triggera przesyłki nie są już brane za identyfikator zamówienia. Własne klucze (SHIPMENT_ID, USER_ID) zostają krótkie.
Zmienne szablonów wiadomości
src/template-vars.js rejestruje przy starcie grupę shipment (Templates.registerVarGroup, requires: ['SHIPMENT_ID']), dostępną w szablonach e-maili automatyzacji uruchamianych np. triggerem shipment_created:
| Zmienna | Źródło w rejestrze |
|---|---|
{{ core/shipping:shipment.trackingNumber }} | trackNo |
{{ core/shipping:shipment.carrier }} | carrier — nazwa usługi zwrócona przez moduł przewoźnika albo wpisana przy przesyłce ręcznej |
{{ core/shipping:shipment.trackingUrl }} | trackingUrl (pusty, gdy przesyłka nie ma linku) |
Brak SHIPMENT_ID albo przesyłki kończy handler błędem, który rdzeń zgłasza w rejestrze problemów.
Metody
| Metoda | Uprawnienie | Opis |
|---|---|---|
carriers.get | — | Przewoźnicy zebrani hookiem core.shipping.getCarrier (po walidacji kontraktu) |
carriers.getForOrder (orderId) | shipments.view | Przewoźnicy podpowiadani warunkami dostawy dla zamówienia (kafelki szybkiego nadania) — nie ogranicza, kim można nadać; brak warunków → wszyscy; warunki bez dopasowania → []; wpis z dopasowanego warunku ma doklejone carrierOptions |
getQuickShipOptions (orderId) | shipments.view | Opcje szybkiego nadania od przewoźników podpowiedzianych warunkami; błąd jednego przewoźnika nie zabiera opcji pozostałych; opcje niezgodne z kontraktem lub wskazujące instancję spoza podpowiedzianych są odrzucane; każda opcja dostaje carrierLogoFile i carrierInitials na awatar kafelka |
getShipmentMenu (orderId) | shipments.view | Schemat OVE menu „Dodaj paczkę” (dropdown): pozycja „Nadaj z <nazwa>” dla każdego podłączonego przewoźnika z shipmentFormMethod (nie tylko podpowiedzianych warunkami) + stały blok „Własna etykieta” |
getShipmentForm (orderId, instanceId?) | shipments.edit (pomijane przy pustym instanceId) | Schemat OVE formularza nadania wskazanej instancji (shipmentFormMethod); pusty instanceId → tani widok zastępczy „Wybierz przewoźnika z listy” bez budzenia kontenerów przewoźników; błąd wywołania, { error } albo odpowiedź bez view.type → widok tekstowy z komunikatem |
ship (orderId, instanceId, method, params?) | shipments.edit | Router nadania: obsługuje oba przepływy — kafelek szybkiego nadania i formularz przewoźnika. Lock per orderId (TTL 180 s); sprawdza tylko, czy instanceId jest w ogóle zarejestrowanym przewoźnikiem (nie czy pasuje do warunków dostawy — nadać można dowolnym podłączonym przewoźnikiem); dokłada carrierOptions z dopasowanego warunku, jeśli jest; callInstanceMethod, walidacja CreatedShipment, zapis przesyłki (+ etykieta, jeśli zwrócona), event shipment.created |
shipments.createManual (orderId, carrier, trackNo, trackingUrl?, status?, label?) | shipments.edit | Ręczna przesyłka (source: 'manual', bez instanceId, więc poza cronem) z opcjonalną wgraną etykietą |
shipments.search (orderId?, orderIds?, status?, instanceId?, search?, dateFrom?, dateTo?, pagination?) | shipments.view | Surowy rejestr z filtrami i paginacją (limit ≤ 200), sort createdAt: -1; search to fragment numeru listu, dateFrom/dateTo filtrują createdAt (górna granica domykana do końca dnia) |
shipments.searchForView (status?, instanceId?, dateFrom?, dateTo?, query?, sortBy?, sortDirection?, page?, limit?) | shipments.view | Rejestr dla strony „Przesyłki”: te same filtry rejestru co shipments.search (status, przewoźnik, zakres dat; bez orderId/orderIds) plus sortowanie z whitelisty (createdAt, statusDate, trackNo, carrier, status; domyślnie createdAt: -1). query szuka równolegle po numerze listu i po zamówieniu (numer, klient) — identyfikatory zamówień rozwiązuje core/orders → searchIds, a warunek to wtedy $or: [{ trackNo }, { orderId: { $in } }]; fraza bez trafionych zamówień zostaje samym numerem listu. Wiersze mają pola widoku co shipments.getByOrderId + kontekst zamówienia: orderName (numer ze źródła, w zastępstwie nazwa zamówienia), orderCustomer, orderStatusName, orderStatusColor. Paginacja jest płaska (page/limit), a nie zagnieżdżona w pagination jak w shipments.search — dataSource tabeli OVE ustawia parametry strony na najwyższym poziomie. Odpowiedź { items, total, page, limit }; strona jest przycięta do 100 wierszy (tyle wynosi perPage w core/orders → search, czyli maksymalny kontekst z jednego wywołania) |
shipments.getById (_id) | shipments.view | Jedna przesyłka wzbogacona o te same pola co shipments.getByOrderId |
shipments.getByOrderId (orderId) | shipments.view | Przesyłki zamówienia wzbogacone o statusLabel, statusColor, statusDateStr, createdAtStr, codAmountLabel, trackingAvailable, labelAvailable, carrierLogoFile, carrierInitials oraz createdByName (imię i nazwisko autora z Core.getUser) |
shipments.setStatus (_id, status, carrierStatus?) | shipments.edit | Zmiana statusu; ustawia statusDate i emituje event. Bez faktycznej zmiany statusu zapisuje tylko podany carrierStatus (bez statusDate i bez eventu), a gdy go nie podano — nie zapisuje nic |
shipments.setLabel (_id, label) | shipments.edit | Zapis (lub zamiana) etykiety w shipment_labels, hasLabel = true |
shipments.getLabel (_id) | shipments.view | LabelFile; gdy brak lokalnie, a przewoźnik ma generateLabelsMethod — pobiera, zapisuje i zwraca |
shipments.generateLabelsForOrders (orderIds[]?, shipmentIds[]?) | shipments.view | Etykiety wskazanych przesyłek lub przesyłek wskazanych zamówień; brakujące dociąga jednym wywołaniem na instancję; wynik { labels }; błąd jednego przewoźnika nie zabiera etykiet pozostałych |
shipments.getLabelsArchive (orderIds[]?, shipmentIds[]?) | shipments.view | Te same etykiety spakowane jszip-em w jedno archiwum: { contentBase64, mimeType: 'application/zip', filename: 'etykiety-RRRR-MM-DD.zip' } dla akcji OVE downloadFile. Pliki w archiwum nazywa <trackNo>.<ext> (rozszerzenie z mimeType), powtórzone numery listów dostają sufiks -2, -3; brak jakiejkolwiek etykiety → błąd, a nie puste archiwum |
shipments.refreshStatusForOrders (orderIds[]?, shipmentIds[]?) | shipments.edit | Odświeżenie otwartych przesyłek — wskazanych wprost albo należących do wskazanych zamówień; { updated: [{ _id, status, carrierStatus }] } — tylko faktyczne zmiany |
shipments.refreshStatusForShipment (_id) | shipments.edit | Odświeżenie jednej przesyłki; zwraca przesyłkę (przy odpowiedzi niezgodnej z kontraktem — bez zmiany) |
conditions.search (pagination?) | conditions.view | Warunki doboru przewoźnika z paginacją (limit ≤ 200) |
conditions.create | conditions.edit | Tworzy warunek + event |
conditions.update (_id) | conditions.edit | Aktualizuje warunek + event |
conditions.remove (_id) | conditions.edit | Usuwa warunek + event |
Warunek zapytania do rejestru składa jedna czysta funkcja buildShipmentsQuery (src/lib/shipments-query.js), więc shipments.search i shipments.searchForView filtrują dokładnie tak samo — różnią się tylko frazą (search po numerze listu vs query także po zamówieniu) i sortowaniem.
Operacje zbiorcze (generateLabelsForOrders, getLabelsArchive, refreshStatusForOrders) wybierają przesyłki przez wspólny resolver findShipmentsForBulk (src/shipments.js): orderIds obsługuje wywołania moduł→moduł, shipmentIds — zaznaczenie w tabeli przesyłek. Przynajmniej jedna z list jest wymagana (inaczej czytelny błąd), obie podane naraz zawężają wybór wzajemnie, a liczbę przesyłek w jednym przebiegu ogranicza BULK_LIMIT (src/lib/pagination.js). Samo dociąganie etykiet (lokalne + jedno wywołanie na instancję przewoźnika) żyje w collectLabelsForShipments (src/labels.js) — metoda listująca i archiwum ZIP idą tą samą ścieżką. Pominięcia (przewoźnik bez generateLabelsMethod, błąd wywołania) trafiają do Log.warn i nie przerywają pozostałych.
Cały przebieg wzbogacania listy (przewoźnicy + autorzy + mapowanie pól) żyje w enrichShipmentsForView (src/shipments.js), więc shipments.getByOrderId i shipments.searchForView zwracają dokładnie ten sam kształt wiersza.
Autora przesyłki (createdByName) rozwiązuje src/users.js: metody widokowe zbierają unikalne createdBy i pytają rdzeń raz na użytkownika, a nie raz na wiersz. Core.getUser zwraca null, gdy użytkownika nie ma, został usunięty albo nie należy do firmy — przyczyny są nierozróżnialne, więc w każdym z tych przypadków (a także gdy wywołanie rzuci, co trafia do Log.warn) pole jest puste. Identyfikator nie jest podstawiany jako zastępnik, a jeden niedostępny użytkownik nie przerywa listy.
Kontekst zamówienia dokłada src/orders.js — jedyne miejsce w module, które woła core/orders (poza doborem przewoźnika po warunkach dostawy w src/carriers.js). getOrdersByIds pyta raz na całą stronę wyników (search z _ids, perPage = liczba unikalnych zamówień, twardo ≤ 100), a findOrderIdsByQuery przekłada frazę operatora na identyfikatory zamówień (searchIds). Awaria albo wyłączenie modułu zamówień nie przerywa listy przesyłek: wywołanie trafia do Log.warn, kontekst wraca pusty, a wiersze pokazują samą przesyłkę — ta sama zasada co przy nazwach autorów.
Pola tekstowe wzbogaconej przesyłki są sformatowane w hubie (statusLabel, statusDateStr, createdAtStr, codAmountLabel), ale nigdy nie niosą zaślepek — brak wartości to pusty string. Czym wypełnić pustą komórkę, decyduje widok: kolumny tabeli mają emptyText: '—'. Moduł czytający te metody przez callModule dostaje więc dane, a nie napisy w rodzaju „Brak”.
Metody ładuje Utils.loadMethods z katalogu methods/ — nazwa podkatalogu staje się prefiksem (methods/shipments/getLabel.js → shipments.getLabel).
Listę przewoźników (wynik execHook('core.shipping.getCarrier')) getCarriers() trzyma w krótkim cache w pamięci procesu (TTL 30 s, src/carriers.js) — menu, formularz i kafelki tej samej strony zamówienia czytają ją przy każdym wejściu, a execHook budzi kontenery wszystkich przewoźników firmy. Unieważniania nie ma celowo: świeżo włączony albo wyłączony moduł przewoźnika pojawia się (lub znika) z listy najdalej po tym czasie, co jest dla operatora niezauważalne.
Metoda dla wywołań moduł→moduł
shipments.register (orderId, instanceId, moduleCode + pola CreatedShipment) rejestruje przesyłkę nadaną poza routerem ship — np. przez automatyzację modułu przewoźnika. Wywołanie moduł→moduł przechodzi bez uprawnienia (ctx.caller), wywołanie użytkownika wymaga shipments.edit. Przewoźnik nie woła tej metody po nadaniu przez ship — tam przesyłkę zapisuje sam hub.
Uprawnienia
Rejestrowane w src/permissions.js, egzekwowane w handlerach przez requirePermission(code, ctx):
| Kod | Opis |
|---|---|
shipments.view | Podgląd przesyłek |
shipments.edit | Nadawanie i edycja przesyłek |
conditions.view | Podgląd warunków doboru przewoźnika |
conditions.edit | Edycja warunków doboru przewoźnika |
Wywołania moduł→moduł (ctx.caller ustawione przez rdzeń) są przepuszczane bez sprawdzania — nie mają tabeli uprawnień użytkownika, a rdzeń zweryfikował już nadawcę.
Strony (OVE)
Widoki to wyłącznie YAML w formacie OVE. Strony rejestruje src/pages.js (Pages.registerSettingsSections + Pages.registerPages) przy starcie i ponownie w Core.onEnabled; rdzeń zapisuje ich treść w bazie (CompanyPage) i serwuje frontendowi bez budzenia kontenera.
| Strona | Opis |
|---|---|
/shipments | Pozycja Przesyłki w navbarze — rejestr przesyłek całej firmy. Tabela na shipments.searchForView (przewoźnik z logo i numer listu ze śledzeniem, zamówienie z klientem i statusem, status przesyłki, pobranie, data utworzenia, pracownik), panel filtrów (statusy, przewoźnik, zakres dat) i wyszukiwarka po numerze listu / zamówieniu / kliencie. Akcje wiersza: odświeżenie statusu i pobranie etykiety; kliknięcie w wiersz prowadzi do zamówienia (navigate do core/orders), a kliknięcie w badge statusu otwiera modal shipments.setStatus. Zaznaczenie wierszy odsłania pasek operacji zbiorczych: shipments.refreshStatusForOrders i shipments.getLabelsArchive (etykiety w jednym ZIP-ie) dla shipmentIds. |
/conditions | Sekcja ustawień „Przesyłki”. Tabela warunków (instancja przewoźnika, źródła zamówienia, kraje, zakres wagi, frazy przewoźnika, ostatnia zmiana) z tworzeniem, edycją i usuwaniem we wspólnym modalu — przewoźnik wybierany z optionsSource → carriers.get (idKey: instanceId, labelKey: name). Obok karta „Zarejestrowani przewoźnicy” z badge'ami wspieranych metod kontraktu (szybkie nadanie / śledzenie / etykiety) oraz opis działania warunków. Pole carrierOptions nie jest edytowalne z UI — ustawia się je metodą conditions.update. |
Kliknięcie numeru zamówienia zostawia przed nawigacją cel powrotu w zmiennej globalnej ordersReturnTarget — { module: 'core/shipping', page: 'shipments', label: 'przesyłek' }. To kontrakt core.orders: karta zamówienia renderuje z niej przycisk „Wróć do przesyłek” (tytuł z label, navigate z module/page) i dzięki temu nie zna tego modułu. Wejście na listę przesyłek zeruje zmienną (onLoad → value: null), żeby przycisk nie został po poprzedniej wizycie.
Strona przesyłek trzyma stan filtrów (shpFiltersOpen) i zaznaczenia (shpSelectedIds) w scope: page; w odróżnieniu od slotu /details/:orderId nie ma parametru w ścieżce, więc localStorage strony nie miesza danych różnych zamówień. Zaznaczenie jest zerowane przy wejściu na stronę (onLoad) i po udanej operacji zbiorczej, bo po niej przestaje odpowiadać checkboxom w tabeli. Wielkość strony jest ograniczona do 100 wierszy — tyle kontekstu zamówień daje jedno wywołanie core/orders → search. Operacje zbiorcze nie siedzą w actions tabeli, tylko w osobnym pasku nad nią: DataTableView przepisuje z wpisu actions wyłącznie pola prezentacyjne, więc renderIf (a z nim ukrycie przycisków przy pustym zaznaczeniu) by tam przepadło. Strona jest własna, więc html/cssbox byłyby dozwolone — z surowego CSS korzysta jednak tylko jedna reguła (kursor klikalnego wiersza); reszta to natywne widgety.
Fragment strony zamówienia (pages/slots/order-details.yaml) nie jest rejestrowaną stroną — dostarcza go handler hooka views.orderDetails, który podstawia placeholder @orderId. Zawiera kartę „Przesyłki” z:
- menu „Dodaj paczkę” w tytule karty — schemat
dropdownbudowany przez moduł (getShipmentMenu+
src/lib/menu-schema.js, widok dynamic + schemaFrom), bo lista pozycji zależy od tego, którzy przewoźnicy są w danej chwili podłączeni: po jednej pozycji „Nadaj z <nazwa>” na przewoźnika z shipmentFormMethod, plus stały blok „Własna etykieta”,
- modalem
shpFormModalz formularzem nadania — treść to widokdynamicnagetShipmentForm, a
kliknięcie pozycji menu ustawia zmienną z wybraną instancją, przeładowuje ten widok i otwiera modal; formularz to obcy schemat dostarczony przez moduł przewoźnika i wraca do karty wyłącznie nazwanymi akcjami shipmentCreated / shipmentFailed (actionDefinitions karty),
- kafelkami szybkiego nadania (
getQuickShipOptions→ routership) — podpowiedzią z warunków
dostawy dla zamówienia bez przesyłek,
- tabelą przesyłek (
datatablenashipments.getByOrderId: numer listu z linkiem do śledzenia,
klikalny status, pobranie, data, pracownik, „Odśwież status” i pobranie etykiety),
- modalem ręcznej przesyłki (
filepickerw trybie base64 →shipments.createManual).
Logo przewoźnika renderuje widok image czytający carrierLogoFile z bucketu instancji przewoźnika; przesyłka ręczna i przewoźnik bez logo dostają avatar z carrierInitials. Oba warianty są rozłączne (renderIf na długości nazwy pliku), bo image bez instancji albo bez nazwy ostrzega w konsoli.
Kafelki szybkiego nadania startują schowane: getQuickShipOptions budzi kontenery wszystkich podpowiedzianych przewoźników, więc zamówienie, które przesyłki już ma, nie ma powodu ich wołać. Odsłania je wyłącznie pusta odpowiedź tabeli przesyłek (datatable publikuje dane także dla zera wierszy) — menu „Dodaj paczkę” nie ma już pozycji, która by je odsłaniała, bo nadanie dowolnym przewoźnikiem idzie teraz przez formularz w modalu. Formularz z kolei pobiera się raz z pustym instanceId razem ze stroną (bez renderIf) i dostaje wtedy z huba tani widok zastępczy „Wybierz przewoźnika z listy”, żeby to pierwsze wywołanie nie budziło kontenerów przewoźników; właściwy formularz pobiera dopiero reload z pozycji menu. Cały stan przejściowy UI idzie w scope: view — zakres page to localStorage pod identyfikatorem strony wspólnym dla wszystkich zamówień.
Fragment renderuje emitent jako schemat z zewnątrz, więc obowiązują ograniczenia schemaGuard: bez html, bez cssbox, bez klucza components (actionDefinitions są dozwolone). Identyfikatory widoków są prefiksowane shp, żeby nie kolidowały z fragmentami innych modułów na tej samej stronie.
Rejestr przesyłek
shipments (src/models/Shipment.js)
Centralny rejestr przesyłek nadanych do zamówień: orderId, instanceId? (instancja przewoźnika, pusta dla przesyłek ręcznych), moduleCode?, source (carrier | manual, domyślnie carrier), carrier (nazwa wyświetlana), trackNo, trackingUrl?, status (domyślnie sent), statusDate (moment wejścia w bieżący status — podstawa backoffu), codAmount?, parcels? (lista paczek; x, y, z, weight — każde opcjonalne, bo przewoźnicy podają różne komplety danych), carrierStatus? (lastUpdate, statusDate, status), carrierData (dane przewoźnika), hasLabel, createdBy?, lastMod, createdAt. Indeksy: { orderId: 1 }, { trackNo: 1 }, { status: 1, statusDate: 1 }.
Statusy (src/lib/shipment-status.js — jedno źródło prawdy dla modelu, walidatorów, cronu i YAML-i): waiting, sent, in-road, delivered, delivery-problems, returned, ready_to_pickup, canceled, error, untracked, unknown. Statusy „otwarte”, czyli odpytywane przez cron i shipments.refreshStatusForOrders: waiting, sent, in-road, ready_to_pickup, unknown. Plik trzyma też etykiety PL (STATUS_LABELS) i kolory badge'y (STATUS_COLORS) dla widoków.
shipment_labels (src/models/ShipmentLabel.js)
shipmentId (indeks unikalny — jedna etykieta na przesyłkę, ponowny zapis zamienia poprzednią), contentBase64, mimeType, filename?, createdAt.
Osobna kolekcja, bo DB.find w SDK nie obsługuje projekcji — inaczej każda lista przesyłek ciągnęłaby base64 plików. Flaga Shipment.hasLabel pozwala UI pokazać przycisk bez pobierania pliku.
delivery_conditions (src/models/DeliveryCondition.js)
sourceModuleCodes?, countryCodes?, weightFrom?, weightTo?, carrierNames?, carrierInstanceId (wymagany), carrierOptions (domyślnie {}), lastMod, createdAt.
Warunki doboru przewoźnika
Warunek podpowiada, która instancja przewoźnika pasuje do zamówienia — steruje wyłącznie kafelkami szybkiego nadania (carriers.getForOrder, getQuickShipOptions). Nie ogranicza, kim można nadać: menu „Dodaj paczkę” i router ship (src/ship.js) obsługują dowolnego podłączonego przewoźnika, a warunek dokłada mu tylko carrierOptions, gdy akurat pasuje. Dopasowanie liczy czysta funkcja matchConditions(order, conditions, productsWeight) z src/lib/conditions-match.js:
- brak zdefiniowanych warunków → podpowiadani są wszyscy zarejestrowani przewoźnicy,
- warunek pasuje, gdy spełnione są wszystkie wypełnione kryteria naraz; puste pole (brak wartości
albo pusta tablica) nie ogranicza dopasowania,
sourceModuleCodes—order.source.moduleCodemusi być na liście,countryCodes—order.delivery.address.countrymusi być na liście (porównanie po trim + wielkie litery),weightFrom/weightTo— waga produktów w kg, granice włącznie;0/nulloznacza brak granicy,carrierNames— jedna z fraz musi wystąpić worder.delivery.carrierName(bez rozróżniania wielkości liter),- warunki istnieją, ale żaden nie pasuje → dla zamówienia nie ma żadnej podpowiedzi (
[], kafelki
się nie pokazują; nadać nadal można dowolnym przewoźnikiem z menu),
- warunek wskazujący instancję, której już nie ma (moduł wyłączony), jest pomijany,
- kilka warunków wskazujących tę samą instancję daje przewoźnika na liście raz — wygrywa pierwszy
pasujący warunek (kolejność z bazy), bo to jego carrierOptions trafiają do metod przewoźnika.
Wagę liczy getOrderProductsWeight(order) z src/lib/order-weight.js: suma po order.products, weight × qty. Jeśli produkt ma niepustą tablicę pack (zestaw), waga liczona jest z jej elementów (pack[].weight × pack[].qty × product.qty), a własne weight produktu jest wtedy ignorowane. Wynik zaokrąglany do dwóch miejsc po przecinku; produkt bez wagi nie podnosi sumy.
carrierOptions z dopasowanego warunku są doklejane do wywołań metod przewoźnika (quickShipOptionsMethod, shipmentFormMethod i metody nadającej w routerze ship) — także wtedy, gdy przewoźnik nadaje spoza podpowiedzi warunków; przewoźnik bez dopasowania dostaje pusty obiekt i nadaje na domyślnych ustawieniach swojego modułu (getCarrierOptionsForOrder, src/carriers.js).
Cron statusów
src/status-checker.js, uruchamiany w Core.onEnabled, zatrzymywany w Core.onDisabled:
- harmonogram
15 */15 * * * *(co 15 minut, sekunda 15 odsuwa przebieg od pełnych kwadransów), - lock
Core.getLock('parcelStatusChecker', 290)— TTL podaje się w sekundach (rdzeń przekazuje
go do jobsregister; JSDoc SDK błędnie mówi o milisekundach); brak locka kończy przebieg bez pracy,
- dla każdego przewoźnika z
getTrackingStatusMethodpobierane są jego przesyłki w statusach
otwartych ({ instanceId, status: { $in: OPEN_STATUSES } }, limit 1000),
- o odpytaniu decyduje
shouldCheckByBackoff(statusDate || createdAt, now)zsrc/lib/status-backoff.js, - błąd pojedynczej przesyłki jest logowany (
Log.warnzcarrier,trackNo,error) i nie przerywa pętli;
odpowiedź niezgodna z TrackingStatus zostawia przesyłkę w dotychczasowym statusie.
Backoff liczony od ostatniej zmiany statusu:
| Wiek statusu | Częstotliwość |
|---|---|
| 0–24 h | każdy przebieg (co 15 min) |
| 24–48 h | co 2 h |
| 48–72 h | co 12 h |
| 72 h – 7 dni | raz dziennie |
| ≥ 7 dni | przesyłka oznaczana jako untracked |
Trafienie w interwał ma tolerancję jednego przebiegu (15 min). Brak statusDate (stare dane) oznacza sprawdzenie, żeby przesyłka nie utknęła. Odpowiedź z tym samym statusem nie rusza statusDate (inaczej backoff resetowałby się przy każdym przebiegu) — zapisywany jest wtedy tylko świeży carrierStatus.
Testy
npm test
Testy (node:test) wymagają Node ≥ 22 — skrypt używa --test-force-exit:
source ~/.nvm/nvm.sh && nvm use 22 && npm test
Różnice względem starego modułu (ordershub core.shipping)
- rejestr przesyłek przeniesiony z modułu
ordersdo hubu — przesyłkę może nadać dowolny moduł
przewoźnika, a zamówienie może istnieć bez przesyłki,
- tożsamością przewoźnika jest
instanceId(a niemoduleId), wywołania idą przez
Core.callInstanceMethod; w warunkach carrierModuleId → carrierInstanceId,
- etykiety trzymane w bazie modułu (kolekcja
shipment_labels, base64) zamiastprintFID
i modułu wydruku,
- SDK nie udostępnia cache, więc hub trzyma własny krótki cache (TTL 30 s,
src/carriers.js)
na wynik hooka core.shipping.getCarrier — bez unieważniania, bo dokładność nie musi być natychmiastowa,
- warunki dostawy tylko podpowiadają przewoźnika kafelkom szybkiego nadania — nadać można
dowolnym podłączonym przewoźnikiem przez menu „Dodaj paczkę” i formularz (shipmentFormMethod),
- przesyłkę z wyniku metody nadającej zapisuje sam hub (router
ship) — moduł przewoźnika
nie woła shipments.create.
Poza zakresem tej iteracji
- Formularz
carrierOptionsna stronie warunków generowany zquickShipParamsMethod
(wymaga mapowania DeclarativeSchema → kontrolki OVE).
- Formularz nadania (
shipmentFormMethod) obsługuje jedną paczkę i pokazuje dane nadawcy
i odbiorcy tylko do wglądu, bez edycji — wiele paczek i edycja stron to osobny etap.
- Automatyczne nadawanie przesyłki u przewoźnika (wydruk gotowej etykiety jest obsługiwany).
- Migracja danych ze starego systemu.
Wymienni dostawcy wydruku
Przyciski drukarki w widoku zamówienia i na liście przesyłek wywołują zawsze shipments.printLabel. Hub wybiera jedyną aktywną instancję zwróconą przez hook core.printing.getProvider, dzięki czemu UI nie zależy od PrintNode ani od lokalnego programu drukującego.
Dostawca zwraca definicję:
Core.registerHook('core.printing.getProvider', () => ({
name: 'Nazwa widoczna w komunikacie',
moduleCode: Core.getModuleInfo().code,
instanceId: Core.getInstanceId(),
printShipmentLabelMethod: 'providerPrintShipmentLabel',
}));
Metoda dostawcy dostaje shipmentId, requestedByUserId, copies, title oraz opcjonalny stabilny idempotencyKey. Wynik synchroniczny powinien mieć { success: true, completed: true }; lokalna kolejka może zwrócić { success: true, completed: false, pending: true, jobId }.
Gdy aktywnych jest zero albo więcej niż jeden dostawca, hub odmawia wydruku z czytelnym błędem. Akcja automatyzacji Wydrukuj etykiety zamówienia korzysta z tego samego kontraktu i czeka na potwierdzenie dostawcy asynchronicznego.
Wpis SHIPMENT_LABEL_PRINTED w historii powstaje tylko dla wydruku zleconego przez użytkownika (shipments.printLabel). Akcja automatyzacji czeka przez waitAndRetry, czyli uruchamia execute od nowa przy każdej próbie — wpis na każdym obiegu zaśmieciłby historię powtórzeniami tego samego zdarzenia.
Daty — strefa czasowa firmy
Daty utworzenia i statusu przesyłek, granice dziennych filtrów (dateFrom/dateTo) oraz data w nazwie archiwum etykiet są liczone w strefie czasowej firmy (Core.getCompany().timezone, domyślnie Europe/Warsaw), niezależnie od strefy kontenera. Od 2.18.0 moduł korzysta z Time z @orbito/api-lib (≥ 4.3.0): strefę pobiera każde wywołanie metody (Time.forCompany()), więc zmiana strefy przez administratora działa bez restartu. Dotyczy również przesyłek InPost. Rejestr nadal zapisuje nieprzesunięte znaczniki czasu UTC.
Zmiana uwzględnia czas letni i zimowy. Nie wykonuje migracji istniejących danych.
Aktualizacja ręcznych przesyłek przez hook
shipping.shipments.createManual przyjmuje opcjonalne courierCode, trackingSource (auto, manual, none), needsVerification i externalId. Pusty trackNo jest poprawny dla etykiety bez rozpoznanego numeru. externalId jest zapisywane w importSource razem z ID instancji i kodem importera z uwierzytelnionego kontekstu; nie pochodzi z deklaracji tożsamości przesłanej przez klienta.
shipping.shipments.updateManual przyjmuje _id, carrier, trackNo oraz opcjonalne courierCode, trackingSource, needsVerification, trackingUrl i label. Pusty trackingUrl usuwa link, pominięcie pola zachowuje dotychczasowy adres. Aktualizuje istniejący wpis source: manual, zachowując ID, zamówienie i status. Nie tworzy nowej przesyłki, nie nadaje paczki i nie emituje ponownie zdarzenia utworzenia. Zwraca pełny zapisany wpis z hasLabel. Wymaga uprawnienia shipments.edit, również gdy uwierzytelniony klient hooka ma jawnie ograniczoną listę uprawnień.
Klient wybiera konkretną instancję dostawcy przez requestHook. Po niepewnym wyniku zapisu powinien odczytać przesyłkę i etykietę, zanim podejmie decyzję o ponowieniu. Flaga needsVerification wyświetla ostrzeżenie w istniejących widokach przesyłek.
Ukrywanie dokumentów w Orbito
Akcja shipments.remove (_id) jest dostępna administratorowi przy przesyłce na karcie zamówienia oraz w rejestrze przesyłek. Ukryte przesyłki nie trafiają na listy i flagi zamówień, do wydruków, pobierania etykiet ani sprawdzania statusów. Ponowny import tej samej przesyłki z tej samej instancji źródła zachowuje ukryty wpis.
Przycisk Ukryj w Orbito wymaga potwierdzenia. Metoda sprawdza uwierzytelniony kontekst użytkownika i uprawnienia administratora (*); zwykła edycja i wewnętrzne wywołania modułów nie dają dostępu. Zapisuje isRemoved, removedAt i removedBy, zachowując dokument, pliki oraz tożsamość źródła. Nie wywołuje anulowania ani usunięcia u dostawcy i nie rozsyła zdarzenia usuwania. Starsze wpisy bez isRemoved pozostają widoczne.
Dostawa obsługiwana przez źródło zamówienia
Opcjonalny kontrakt shipping.source.getCarrierSelection({orderId, sourceUniqueId}) jest kierowany do zapisanego source.moduleId i source.moduleCode. Odpowiedź null pozostawia zwykłe warunki dostawy. {exclusive: true, instanceIds: [...]} zastępuje podpowiedzi listą wskazanych kont; pusta lista oznacza brak obsługiwanej usługi. Brak implementacji zachowuje zgodność ze starszymi źródłami, a niejednoznaczna lub niepotwierdzona odpowiedź nie zastępuje wskazanego konta innym. Nadanie ręczne z menu pozostaje dostępne.
Opcja przewoźnika z openForm: true otwiera istniejący formularz wskazanej instancji zamiast nadawać po kliknięciu kafelka. Logo i opcja zawsze pochodzą od tej samej instancji przewoźnika.
Nazwane paczki w warunkach doboru przewoźnika
W Ustawienia → Przesyłki → Warunki doboru przewoźnika dodaj warunek, wybierz konkretne konto przewoźnika, wpisz nazwę kafelka i wymiary wysokość × szerokość × długość w cm. Ustaw frazę z nazwy dostawy, np. InPost, oraz zakres od/do kg. Jeden warunek tworzy jeden kafelek; dla A/B/C dodaj trzy warunki. Obie granice są włącznie, puste/0 oznacza brak ograniczenia. Pusta nazwa i wymiary zachowują dotychczasowe opcje integracji.
Waga nadania pochodzi z wszystkich produktów zamówienia, ich ilości i składników zestawów. Brak wagi produktu blokuje nadanie. Funkcja nie dzieli zamówień automatycznie ani nie ogranicza liczby butelek.
Nie ma osobnej zakładki szablonów. Starsze przypisania działają dalej. Pojedynczy przypisany szablon można edytować w formularzu warunku; zapis przekształca go w paczkę tego warunku i zachowuje przecięcie obu zakresów wagi. Współdzielony stary szablon nie jest zmieniany ani usuwany. Przy kilku wcześniejszych przypisaniach formularz zachowuje ich wybór; wpisanie własnej nazwy i wymiarów zastąpi je jednym kafelkiem. Metody templates.* pozostają zgodne z wcześniejszym API, ale nie są potrzebne do nowej konfiguracji.
Kafelki korzystają z logo konta nadającego. Integracje muszą deklarować obsługę parcelTemplates i mapować wymiary przez kontrakt shipping.carrier.getQuickShipOptions. Zmiana wagi, wymiarów lub warunku po wyświetleniu kafelka wymaga odświeżenia zamówienia przed nadaniem.
Konfiguracja startowa dostawcy
Odczyt shipping.conditions.capabilities zwraca { sourceScopes: ['owner', 'all'], maxPresets: 150 }. Dostawca powinien sprawdzić obsługę potrzebnego zakresu przed instalacją, żeby starszy rejestr nie zignorował nieznanego pola.
Kontrakt shipping.conditions.provision instaluje raz szablony uwierzytelnionego konta przewoźnika. Odbiorca jest wybierany po ID instancji hubu, właściciel po ctx.caller, nigdy po polach payloadu. Domyślnie warunki startowe ograniczają źródło do konta nadawcy. Opcjonalne sourceScope: 'all' w pojedynczym szablonie usuwa ograniczenie źródła dla przewoźników obsługujących zamówienia z dowolnych integracji. Właścicielem szablonu i wykonawcą nadal jest uwierzytelniona instancja przewoźnika; nie można wskazać obcego konta. sourceScope: 'owner' jest jawnym wariantem domyślnym. Opcje usługi należą do konkretnego warunku, a waga do zamówienia. Cały zestaw aktywuje jeden zapis konfiguracji; przerwane zapisy są ukryte i można uzgodnić je bez duplikatów. Oryginalne warunki pozostają w bazie, a kolejne wywołania nie nadpisują zmian użytkownika. dispatchEnabled: false w definicji przewoźnika ukrywa kafelki, formularz i preinstalowane warunki, blokuje nowe nadania i zachowuje dostawcę historycznych etykiet/trackingów.
Opakowania (opcjonalne)
Funkcja jest domyślnie wyłączona. Włącz ją w konfiguracji modułu albo w Ustawienia → Przesyłki → Opakowania. Wyłączenie zachowuje katalog i historyczne plany oraz przywraca dotychczasowe nadawanie. Nierozstrzygnięte nadanie pozostaje zablokowane również po wyłączeniu funkcji.
Karton ma nazwę, wymiary zewnętrzne w cm, masę własną i wypełnienia, zakres rekomendowanej wagi zawartości (od, do], maksymalną wagę brutto oraz kolejność poziomów. Waga nadania obejmuje karton i wypełnienie. Opcjonalne reguły SKU=liczba sztuk określają pojemność; przy mieszanym załadunku suma wykorzystanych części pojemności nie może przekroczyć 1. Bez tych reguł dobór uwzględnia wagę, nie geometrię produktów.
Blok nad przesyłkami pokazuje małe karty z grafiką kartonu, nazwą, wymiarami i maksymalną wagą. Szybki widok pokazuje wyłącznie kartony mieszczące całe zamówienie, których zakres rekomendacji (od, do] obejmuje sumę wag zawartości. Nie dodaje kartonu z wyższego zakresu ani zastępczej opcji spoza zakresu. Domyślnie wybiera pierwszy pasujący karton i od razu pobiera metody dostawy. Pełny katalog pozostaje dostępny w wyborze ręcznym. Kliknięcie innej karty zapisuje wybór i odświeża metody. Niebieski przycisk „Wybierz karton ręcznie” po prawej stronie nagłówka otwiera kreator: najpierw liczba paczek oraz lista pasujących kont przewoźników z informacją o obsłudze wielu paczek dla usługi zamówienia, następnie konfiguracja każdej paczki z grafikami i licznikami −/+ (można również wpisać ilość). Każdy krok oznacza jedną fizyczną paczkę; wybrane w nim kartony są łączone. Liczba podana na początku jest liczbą końcowych paczek. Starsze plany z osobnymi kartonami są przy ponownym otwarciu rozwijane do osobnych kroków, z zachowaniem liczby paczek i kartonów. Przyciski wstecz/dalej zachowują wybory; dopiero „Zastosuj” zapisuje całość. Katalog powyżej 20 kartonów ma strony zachowujące liczniki. Automatyczne warianty obejmują całe zamówienie w jednym kartonie. Ręczny plan przypisuje każdą sztukę dokładnie raz do wybranych kartonów. W połączonym zestawie sumowane są wysokości kartonów, a pozostałe wymiary są ich maksimami. Osobne kartony to kilka paczek w jednym nadaniu do jednej instancji przewoźnika. Produkt sprzedawany jako zestaw pozostaje w całości. Brak pasującego kartonu lub wag blokuje dobór i pokazuje błąd.
Limity: 100 aktywnych kartonów i 500 sztuk w zamówieniu. Zapisany wybór pozostaje zaznaczony przy ponownym otwarciu. Zmiana zamówienia, adresu lub użytego kartonu unieważnia dotychczasowe oferty; odświeżenie widoku dobiera aktualny karton. Nadanie sprawdza aktualność wyboru i oferty przed wywołaniem przewoźnika. Historyczne i nierozstrzygnięte plany pozostają niezmienne.
Opcjonalne price: { amount, currency, basis: 'net' } w ofercie oznacza pełną cenę netto dla danego planu. Hub wyświetla ceny z dopiskiem „netto” i porównuje wyłącznie znane ceny netto w jednej walucie, oznaczając najniższą znaną wycenę. Apaczka i DHL pobierają wyceny we własnych instancjach; hub nie zna ich API ani taryf. Cena brutto lub bez oznaczenia basis: 'net' jest pomijana bez zgadywania stawki VAT. Brak ceny pozostawia puste miejsce, bez tekstu zastępczego i bez uznawania przesyłki za darmową. Kwota 0 jest prawidłową, znaną wyceną. Przy osobnych listach wyświetlana jest suma tylko wtedy, gdy wszystkie listy mają cenę w tej samej walucie. Niepewny wynik nadania blokuje kolejną próbę: trzeba sprawdzić stan u przewoźnika, bez automatycznego ponawiania.
Komunikacja odbywa się przez hooki i wybraną instancję dostawcy. Blok zamówienia jest wkładem views.orderDetails; moduł zamówień nie zależy od implementacji pakowania.
Grafikę PNG/JPG/WebP do 2 MB można wgrać w edycji kartonu i przywrócić grafikę domyślną. Pliki są przechowywane we własnym buckecie instancji. Zwykła edycja zachowuje grafikę; zmiana samej grafiki nie unieważnia potwierdzonego planu.
Plan version: 2 zawiera boxes, rozłączny i kompletny podział indeksów kartonów w groups: [{ boxIndexes, mode, dimensions? }] oraz wynikowe parcels. Wersja 1 pozostaje obsługiwana. Przewoźnik deklaruje packingPlanVersions: [1, 2]; starsza integracja otrzyma komunikat o wymaganej aktualizacji przed nadaniem planu grupowego. Wszystkie grupy są nadawane jedną komendą do jednego wybranego konta; ograniczenia liczby paczek nadal obowiązują.
Automatyczne łączenie kartonów w jednej paczce sumuje szerokości (y), a dla wysokości (x) i długości (z) bierze maksimum. Dwa H 60 × 20 × 10 cm dają 60 × 40 × 10 cm. Wynik jest jawnie zapisany w planie i wyświetlany nad kafelkami pojedynczych kartonów. Zapisane wcześniejsze plany pozostają niezmienione do ponownego potwierdzenia wyboru; historia i niepewne nadania nie są przeliczane.
Po potwierdzonym nadaniu blok opakowań pokazuje tabelę w stylu rejestru przesyłek: ikonka, nazwa i wymiary wybranego kartonu, liczba kartonów, data i godzina wyboru w strefie firmy oraz pracownik. Powtórzone kartony tego samego typu są zliczane. Grafiki przy nowych wyborach są zapisywane jako referencje do niezmiennych plików z chwili wyboru, niezależnie od późniejszej edycji katalogu. Nazwy i wymiary pochodzą z zapisanego planu, a autor z confirmedBy (bez zgadywania autora nadania). Przy niepewnym nadaniu nadal widoczna jest blokada i wybrany plan.
Gdy usługa nie obsługuje podpaczek w jednym nadaniu, aktualny Shipping może nadać każdą fizyczną paczkę na osobnym liście (separateShipments, packingParcelIndex). Pełny plan jest ponownie walidowany przed każdym nadaniem. Pobranie jest dzielone według sum maksymalnych obciążeń kartonów w paczce, w groszach, tak aby suma na wszystkich listach była dokładnie równa kwocie zamówienia. Każdy zapisany list ma własną etykietę i zdarzenie do istniejącej automatyzacji wydruku. Częściowy sukces lub niepewna odpowiedź zatrzymuje serię i blokuje automatyczne ponowienie.
Przy niepewnym nadaniu przechowujemy kod i treść błędu. Dostawca może zadeklarować reconcilePackingDispatchMethod i udostępnić odczytowy kontrakt shipping.carrier.reconcilePackingDispatch({ orderId, packingPlan }). Przycisk „Sprawdź istniejące listy” wybiera wyłącznie instancję z zapisanej próby. Zamknięcie planu wymaga { resolved: true, orderId, packingToken, shipmentIds }, zgodnego hasha planu i po jednym istniejącym, nieanulowanym wpisie na paczkę. Nie nadaje ani nie drukuje ponownie. Historia wyraźnie opisuje wcześniejszy wybór kartonów, bez zmieniania parametrów listów dopisanych ręcznie.
