Add Allegro shipment service and related components
- Implement AllegroShipmentService for managing shipment creation and status checks. - Create ShipmentController to handle shipment preparation and label downloading. - Introduce ShipmentPackageRepository for database interactions related to shipment packages. - Add methods for retrieving delivery services, creating shipments, checking creation status, and downloading labels. - Implement address validation and token management for Allegro API integration.
This commit is contained in:
20
.vscode/ftp-kr.sync.cache.json
vendored
20
.vscode/ftp-kr.sync.cache.json
vendored
@@ -682,14 +682,14 @@
|
||||
"DOCS": {
|
||||
"ARCHITECTURE.md": {
|
||||
"type": "-",
|
||||
"size": 14854,
|
||||
"lmtime": 1772662333693,
|
||||
"size": 15120,
|
||||
"lmtime": 1772664180594,
|
||||
"modified": false
|
||||
},
|
||||
"DB_SCHEMA.md": {
|
||||
"type": "-",
|
||||
"size": 6999,
|
||||
"lmtime": 1772661998528,
|
||||
"size": 7164,
|
||||
"lmtime": 1772664186202,
|
||||
"modified": false
|
||||
},
|
||||
"ORDERS_SCHEMA_APILO_DRAFT.md": {
|
||||
@@ -706,14 +706,14 @@
|
||||
},
|
||||
"TECH_CHANGELOG.md": {
|
||||
"type": "-",
|
||||
"size": 17750,
|
||||
"lmtime": 1772662343016,
|
||||
"size": 18096,
|
||||
"lmtime": 1772664195563,
|
||||
"modified": false
|
||||
},
|
||||
"todo.md": {
|
||||
"type": "-",
|
||||
"size": 688,
|
||||
"lmtime": 1772662690145,
|
||||
"size": 858,
|
||||
"lmtime": 1772664322782,
|
||||
"modified": false
|
||||
}
|
||||
},
|
||||
@@ -2498,8 +2498,8 @@
|
||||
},
|
||||
"OrdersRepository.php": {
|
||||
"type": "-",
|
||||
"size": 24750,
|
||||
"lmtime": 1772658967435,
|
||||
"size": 25388,
|
||||
"lmtime": 1772664174219,
|
||||
"modified": false
|
||||
},
|
||||
"OrderStatusSyncService.php": {
|
||||
|
||||
59
CLAUDE.md
59
CLAUDE.md
@@ -1 +1,58 @@
|
||||
## Za każdym razem jak próbujesz sprawdzić jakiś plik z logami spróbuj go najpierw pobrać z serwera FTP
|
||||
# Projektowe zasady dla Codex
|
||||
|
||||
## Baza danych i migracje
|
||||
- `DB_HOST_REMOTE` jest techniczne tylko dla agenta (Codex) do recznych operacji DB/migracji.
|
||||
- Nie podpinaj `DB_HOST_REMOTE` do runtime aplikacji.
|
||||
- Runtime aplikacji ma korzystac standardowo z `DB_HOST`.
|
||||
|
||||
## Zasady pisania kodu
|
||||
- Kod ma być czytelny „dla obcego”: jasne nazwy, mało magii
|
||||
- Brak „skrótów na szybko” typu logika w widokach, copy-paste, losowe helpery bez spójności
|
||||
- Każda funkcja/klasa ma mieć jedną odpowiedzialność, zwykle do 30–50 linii (jeśli dłuższe – dzielić)
|
||||
- max 3 poziomy zagnieżdżeń (if/foreach), reszta do osobnych metod
|
||||
- Nazewnictwo:
|
||||
- klasy: PascalCase
|
||||
- metody/zmienne: camelCase
|
||||
- stałe: UPPER_SNAKE_CASE
|
||||
- Zero „skrótologii” w nazwach (np. $d, $tmp, $x1) poza pętlami 2–3 linijki
|
||||
- medoo + prepared statements bez wyjątków (żadnego sklejania SQL stringiem)
|
||||
- XSS: escape w widokach (np. helper e())
|
||||
- CSRF dla formularzy, sensowna obsługa sesji
|
||||
- Kod ma mieć komentarze tylko tam, gdzie wyjaśniają „dlaczego”, nie „co”
|
||||
|
||||
## Utrwalanie stalych wymagan
|
||||
- Trwale wymagania techniczne zapisuj w tym pliku (`AGENTS.md`) w root projektu.
|
||||
- Dla zmiennych srodowiskowych utrzymuj tez wpisy w `.env.example`.
|
||||
- Dokumentacje techniczna utrzymuj w folderze `DOCS`:
|
||||
- `DOCS/DB_SCHEMA.md` - aktualny schemat bazy danych (aktualizacja przy kazdej zmianie migracji/schematu),
|
||||
- `DOCS/ARCHITECTURE.md` - struktura klas, metod, modulow i przeplywow,
|
||||
- `DOCS/TECH_CHANGELOG.md` - chronologiczny log zmian technicznych (co i dlaczego).
|
||||
- Przy kazdej nowej funkcji lub zmianie:
|
||||
- zaktualizuj odpowiednie sekcje w `DOCS/DB_SCHEMA.md`, `DOCS/ARCHITECTURE.md`, `DOCS/TECH_CHANGELOG.md`,
|
||||
- opisz nowe tabele/kolumny/indeksy/FK, nowe klasy/metody oraz zmiany kontraktow API.
|
||||
|
||||
## Wdrażanie poprawek
|
||||
- Przed wdrożeniem zmian w kodzie, przeglądnij, poniższe dokumenty aby nowe kody dopasować do istniejacych już rozwiązań:
|
||||
- `DOCS/DB_SCHEMA.md` - aktualny schemat bazy danych
|
||||
- `DOCS/ARCHITECTURE.md` - struktura klas, metod, modulow i przeplywow
|
||||
- Zrób plan i mi go przedstaw
|
||||
- Po akceptacji przejdź do wdrożenia
|
||||
- Po wdrożeniu przeprowadź testy
|
||||
|
||||
## Alerty i potwierdzenia UI
|
||||
- W aplikacji uzywaj modulu `resources/modules/jquery-alerts` (build do `public/assets/js/modules/jquery-alerts.js` i `public/assets/css/modules/jquery-alerts.css`).
|
||||
- Nie dodawaj nowych natywnych `alert()` / `confirm()` w widokach; dla potwierdzen akcji (np. usuwanie) korzystaj z `window.OrderProAlerts.confirm(...)`.
|
||||
|
||||
## Style frontendu
|
||||
- Nie trzymaj styli CSS w plikach widokow (`resources/views/...`).
|
||||
- Wszystkie style umieszczaj w plikach SCSS (`resources/scss/...`) i buduj do `public/assets/css/...`.
|
||||
- Interfejs ma byc kompaktowy: preferuj mniejsze odstepy i gestszy uklad, tak aby pokazywac jak najwiecej informacji na jednym ekranie bez przewijania.
|
||||
|
||||
## Reuzywalnosc UI
|
||||
- Nie powielaj kodu takich samych elementow widoku.
|
||||
- Jezeli ten sam blok UI wystepuje w wiecej niz jednym miejscu, wydziel go do wspolnego komponentu (np. `resources/views/components/...`) i uzywaj ponownie.
|
||||
- Zmiany wspolnego komponentu musza byc propagowane do wszystkich miejsc uzycia.
|
||||
|
||||
## Srodowisko lokalne (Windows)
|
||||
- Komenda `php` jest dostepna z instalacji XAMPP (`C:\xampp\php\php.exe`).
|
||||
- Jezeli `php` nie jest widoczne w terminalu, dodaj `C:\xampp\php` do zmiennej `PATH` (User).
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
- `GET /orders` (redirect do `/orders/list`)
|
||||
- `GET /orders/list`
|
||||
- `GET /orders/{id}`
|
||||
- `POST /orders/{id}/status`
|
||||
- `GET /users` (redirect do `/settings/users`)
|
||||
- `POST /users` (compat route)
|
||||
- `GET /settings` (redirect do `/settings/users`)
|
||||
@@ -40,7 +41,12 @@
|
||||
- `POST /settings/integrations/allegro/statuses/delete`
|
||||
- `POST /settings/integrations/allegro/statuses/sync`
|
||||
- `GET /settings/integrations/allegro/oauth/callback`
|
||||
- `GET /health`, `GET /` (redirect)
|
||||
- `GET /settings/integrations/apaczka`
|
||||
- `POST /settings/integrations/apaczka/save`
|
||||
- `GET /settings/integrations/inpost`
|
||||
- `POST /settings/integrations/inpost/save`
|
||||
- `GET /health`
|
||||
- `GET /` (redirect)
|
||||
|
||||
## Korekta logowania
|
||||
- `AuthController::showLogin(Request): Response`:
|
||||
@@ -72,6 +78,10 @@
|
||||
- `App\Modules\Cron\AllegroStatusSyncHandler`
|
||||
- `App\Modules\Users\UsersController`
|
||||
- `App\Modules\Users\UserRepository`
|
||||
- `App\Modules\Settings\ApaczkaIntegrationController`
|
||||
- `App\Modules\Settings\ApaczkaIntegrationRepository`
|
||||
- `App\Modules\Settings\InpostIntegrationController`
|
||||
- `App\Modules\Settings\InpostIntegrationRepository`
|
||||
- `App\Modules\Settings\AllegroOrdersSyncService`
|
||||
- `App\Modules\Settings\AllegroOrderSyncStateRepository`
|
||||
- `App\Modules\Settings\AllegroStatusSyncService`
|
||||
@@ -158,6 +168,8 @@
|
||||
- `Statusy` (`/settings/statuses`).
|
||||
- `Cron` (`/settings/cron`).
|
||||
- `Integracje Allegro` (`/settings/integrations/allegro`).
|
||||
- `Integracja Apaczka` (`/settings/integrations/apaczka`).
|
||||
- `Integracja InPost` (`/settings/integrations/inpost`).
|
||||
|
||||
## Przeplyw Ustawienia > Cron
|
||||
- `GET /settings/cron`:
|
||||
@@ -229,6 +241,7 @@
|
||||
- `AllegroOrderImportService`:
|
||||
- pilnuje waznosci tokenu (refresh przed requestem lub retry po `401`),
|
||||
- pobiera zamowienie `GET /order/checkout-forms/{id}` przez `AllegroApiClient`,
|
||||
- pobiera przesylki zamowienia `GET /order/checkout-forms/{id}/shipments` przez `AllegroApiClient::getCheckoutFormShipments(...)`,
|
||||
- dla pozycji bez obrazka w checkout-form pobiera szczegoly oferty `GET /sale/product-offers/{offerId}` i uzupelnia `order_items.media_url`,
|
||||
- mapuje forme wysylki Allegro (`delivery.method.name`/`delivery.method.id`) do pol zamowienia (`external_carrier_id`, `external_carrier_account_id`),
|
||||
- dla dostawy do punktu odbioru mapuje adres `delivery.pickupPoint.address` i nazwe punktu do adresu typu `delivery`,
|
||||
@@ -248,6 +261,43 @@
|
||||
- dla kierunku `allegro_to_orderpro` wykorzystuje mechanizm importu zamowien do aktualizacji statusow,
|
||||
- dla kierunku `orderpro_to_allegro` zwraca wynik informacyjny (tryb przygotowany pod kolejny etap).
|
||||
|
||||
## Log aktywnosci zamowien
|
||||
- Tabela `order_activity_log` rejestruje wszystkie zdarzenia dotyczace zamowienia.
|
||||
- Typy zdarzen: `status_change`, `payment`, `invoice`, `shipment`, `message`, `document`, `import`, `note`.
|
||||
- Rejestracja zdarzen: `OrdersRepository::recordActivity(...)`.
|
||||
- Zmiana statusu: `OrdersRepository::updateOrderStatus(...)` — aktualizuje `orders.external_status_id`, wpisuje do `order_status_history` i `order_activity_log`.
|
||||
- Import zamowienia: `AllegroOrderImportService::importSingleOrder(...)` — po upsert zamowienia rejestruje zdarzenie `import` w `order_activity_log` (nowy import lub re-import/aktualizacja), actor_type `import`, actor_name `Allegro`.
|
||||
- Widok szczegolow zamowienia (`GET /orders/{id}`) wyswietla log aktywnosci w zakladce `Historia zmian`.
|
||||
|
||||
## Zmiana statusu zamowienia z widoku szczegolow
|
||||
- `POST /orders/{id}/status`:
|
||||
- `OrdersController::updateStatus(Request): Response`
|
||||
- waliduje CSRF i wybrany status,
|
||||
- wywoluje `OrdersRepository::updateOrderStatus(...)` (aktualizuje `orders.external_status_id`, wpisuje do `order_status_history` i `order_activity_log`),
|
||||
- actor_type: `user`, actor_name: nazwa zalogowanego uzytkownika,
|
||||
- po zapisie redirect do `GET /orders/{id}` z flash message (sukces/blad).
|
||||
- Widok szczegolow zamowienia wyswietla dropdown ze wszystkimi aktywnymi statusami (pogrupowanymi wg grup statusow) obok aktualnego statusu.
|
||||
|
||||
## Przeplyw Ustawienia > Integracja Apaczka
|
||||
- `GET /settings/integrations/apaczka`:
|
||||
- `ApaczkaIntegrationController::index(Request): Response`
|
||||
- odczytuje konfiguracje przez `ApaczkaIntegrationRepository::getSettings()`,
|
||||
- renderuje `resources/views/settings/apaczka.php`.
|
||||
- `POST /settings/integrations/apaczka/save`:
|
||||
- `ApaczkaIntegrationController::save(Request): Response`
|
||||
- waliduje CSRF i klucz API,
|
||||
- zapisuje zaszyfrowany klucz API przez `ApaczkaIntegrationRepository::saveSettings(...)`.
|
||||
|
||||
## Przeplyw Ustawienia > Integracja InPost
|
||||
- `GET /settings/integrations/inpost`:
|
||||
- `InpostIntegrationController::index(Request): Response`
|
||||
- odczytuje konfiguracje przez `InpostIntegrationRepository::getSettings()`,
|
||||
- renderuje `resources/views/settings/inpost.php`.
|
||||
- `POST /settings/integrations/inpost/save`:
|
||||
- `InpostIntegrationController::save(Request): Response`
|
||||
- waliduje CSRF,
|
||||
- zapisuje ustawienia (token API szyfrowany AES-256-CBC, parametry domyslne przesylek) przez `InpostIntegrationRepository::saveSettings(...)`.
|
||||
|
||||
## Przeplyw Ustawienia > Baza danych
|
||||
- `GET /settings/database`:
|
||||
- `SettingsController::database(Request): Response`
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
- 2026-03-04: Rozszerzono zakladke `Ustawienia` integracji Allegro o kierunek synchronizacji statusow i interwal synchronizacji statusow; zapis do `app_settings` (`allegro_status_sync_direction`, `allegro_status_sync_interval_minutes`) - bez zmian schematu.
|
||||
- 2026-03-04: Dodano harmonogram `allegro_status_sync` (cron synchronizacji statusow) + defaulty `app_settings` dla kierunku i interwalu status sync - migracja `20260304_000028_add_allegro_status_sync_schedule.sql`.
|
||||
- 2026-03-04: Import Allegro mapuje forme wysylki do `orders.external_carrier_id` i `orders.external_carrier_account_id` - bez zmian schematu.
|
||||
- 2026-03-05: Dodano tabele `order_activity_log` — uniwersalny log aktywnosci zamowien (zmiany statusow, platnosci, przesylki, faktury, wiadomosci itp.).
|
||||
- 2026-03-05: Dodano tabele `apaczka_integration_settings` pod konfiguracje klucza API Apaczka.
|
||||
- 2026-03-05: Dodano tabele `inpost_integration_settings` pod konfiguracje integracji InPost ShipX.
|
||||
- 2026-03-06: Dodano kolumne `carrier` do tabeli `allegro_delivery_method_mappings` (default 'allegro') - umozliwia mapowanie na roznych przewoznikow (Allegro, InPost).
|
||||
- 2026-03-04: Poprawiono prezentacje daty zamowienia na liscie (`fallback ordered_at -> source_created_at -> source_updated_at -> fetched_at`) - bez zmian schematu.
|
||||
|
||||
## Tabele
|
||||
@@ -122,6 +126,44 @@
|
||||
- `allegro_order_status_mappings_code_unique` (UNIQUE: `allegro_status_code`),
|
||||
- `allegro_order_status_mappings_orderpro_code_idx` (`orderpro_status_code`).
|
||||
|
||||
### `order_activity_log`
|
||||
- Uniwersalny log aktywnosci zamowienia (zmiany statusow, platnosci, przesylki, faktury, wiadomosci itp.).
|
||||
- Kolumny:
|
||||
- `id` (PK, bigint unsigned, AI),
|
||||
- `order_id` (FK -> `orders.id`, CASCADE),
|
||||
- `event_type` (varchar 32) — typ zdarzenia: `status_change`, `payment`, `invoice`, `shipment`, `message`, `document`, `import`, `note`,
|
||||
- `summary` (varchar 255) — czytelny opis zdarzenia,
|
||||
- `details_json` (json, nullable) — dodatkowe dane strukturalne,
|
||||
- `actor_type` (varchar 16, domyslnie `system`) — `system`, `user`, `import`, `api`, `sync`,
|
||||
- `actor_name` (varchar 128, nullable) — nazwa uzytkownika lub identyfikator systemu,
|
||||
- `created_at`.
|
||||
- Indeksy:
|
||||
- `order_activity_log_order_created_idx` (`order_id`, `created_at`),
|
||||
- `order_activity_log_event_type_idx` (`event_type`).
|
||||
|
||||
### `apaczka_integration_settings`
|
||||
- Konfiguracja pojedynczej integracji Apaczka (`id = 1`) zarzadzanej z `Ustawienia > Integracja Apaczka`.
|
||||
- Kolumny:
|
||||
- `id` (PK, tinyint unsigned),
|
||||
- `api_key_encrypted` (text, nullable),
|
||||
- `created_at`, `updated_at`.
|
||||
|
||||
### `inpost_integration_settings`
|
||||
- Konfiguracja pojedynczej integracji InPost ShipX (`id = 1`) zarzadzanej z `Ustawienia > Integracja InPost`.
|
||||
- Kolumny:
|
||||
- `id` (PK, tinyint unsigned),
|
||||
- `api_token_encrypted` (text, nullable),
|
||||
- `organization_id` (varchar 50, nullable),
|
||||
- `environment` (enum: sandbox, production),
|
||||
- `default_dispatch_method` (enum: pop, parcel_locker, courier),
|
||||
- `default_dispatch_point` (varchar 50, nullable),
|
||||
- `default_insurance` (decimal 10,2, nullable),
|
||||
- `default_locker_size` (enum: small, medium, large),
|
||||
- `default_courier_length`, `default_courier_width`, `default_courier_height` (smallint unsigned),
|
||||
- `label_format` (enum: Pdf, Zpl, Epl),
|
||||
- `weekend_delivery`, `auto_insurance_value`, `multi_parcel` (tinyint 0/1),
|
||||
- `created_at`, `updated_at`.
|
||||
|
||||
## Zasady aktualizacji
|
||||
- Po kazdej migracji dopisz:
|
||||
- nowe/zmienione tabele i kolumny,
|
||||
|
||||
@@ -1,5 +1,57 @@
|
||||
# Tech Changelog
|
||||
|
||||
## 2026-03-06
|
||||
- Rozszerzono zakladke `Formy dostawy` o wybor przewoznika (Allegro / InPost) per wiersz:
|
||||
- nowa kolumna `carrier` w tabeli `allegro_delivery_method_mappings`,
|
||||
- select przewoznika determinuje dostepne uslugi (Allegro z API, InPost statyczna lista),
|
||||
- JS przelacza panele uslug w zaleznosci od wybranego przewoznika.
|
||||
- Migracja `20260306_000036_add_carrier_to_delivery_method_mappings.sql`.
|
||||
|
||||
## 2026-03-05
|
||||
- Dodano nowa zakladke `Ustawienia > Integracja InPost`:
|
||||
- route `GET /settings/integrations/inpost` i `POST /settings/integrations/inpost/save`,
|
||||
- widok konfiguracji z polami: token API (szyfrowany), ID organizacji, srodowisko (sandbox/production),
|
||||
- domyslny sposob nadania (POP/paczkomat/kurier), punkt nadania,
|
||||
- domyslny rozmiar paczki (A/B/C), wymiary przesylek kurierskich,
|
||||
- typ etykiety (PDF/ZPL/EPL), paczka weekendowa, auto-ubezpieczenie, multi-paczki.
|
||||
- Dodano klasy:
|
||||
- `App\Modules\Settings\InpostIntegrationController`,
|
||||
- `App\Modules\Settings\InpostIntegrationRepository` (szyfrowanie AES-256-CBC + deszyfrowanie tokenu).
|
||||
- Dodano migracje `20260305_000035_create_inpost_integration_settings_table.sql`.
|
||||
- Rozszerzono nawigacje `Ustawienia` o link `Integracja InPost`.
|
||||
- Import Allegro pobiera przesylki z dedykowanego endpointu `GET /order/checkout-forms/{id}/shipments` zamiast szukac ich w payloadzie checkout form (naprawa zerowej liczby przesylek).
|
||||
- Dodano metode `AllegroApiClient::getCheckoutFormShipments(...)`.
|
||||
- Usunieto duplikat ID zamowienia Allegro w naglowku szczegolow zamowienia (wyswietlane bylo `source_order_id` i `external_order_id` z ta sama wartoscia).
|
||||
- Dodano nazwe integracji (np. "Allegro") przed ID zamowienia w naglowku szczegolow.
|
||||
- Dodano nowa zakladke `Ustawienia > Integracja Apaczka`:
|
||||
- route `GET /settings/integrations/apaczka` i `POST /settings/integrations/apaczka/save`,
|
||||
- widok konfiguracji z polem klucza API (szyfrowany AES-256-CBC jak w integracji Allegro).
|
||||
- Dodano klasy:
|
||||
- `App\Modules\Settings\ApaczkaIntegrationController`,
|
||||
- `App\Modules\Settings\ApaczkaIntegrationRepository`.
|
||||
- Dodano migracje `20260305_000029_create_apaczka_integration_settings_table.sql`:
|
||||
- tabela `apaczka_integration_settings` na konfiguracje klucza API (zaszyfrowany).
|
||||
- Rozszerzono nawigacje `Ustawienia` o link `Integracja Apaczka`.
|
||||
- Dodano reczna zmiane statusu zamowienia z widoku szczegolow:
|
||||
- nowa route `POST /orders/{id}/status`,
|
||||
- nowa metoda `OrdersController::updateStatus(...)`,
|
||||
- dropdown ze wszystkimi aktywnymi statusami (pogrupowane wg grup) w naglowku szczegolow zamowienia,
|
||||
- zmiana rejestrowana w `order_status_history` i `order_activity_log` (actor_type: `user`),
|
||||
- flash messages (sukces/blad) po redirect,
|
||||
- bez zmian schematu.
|
||||
- Import zamowienia z Allegro (reczny i auto-sync) rejestruje zdarzenie `import` w `order_activity_log`:
|
||||
- `AllegroOrderImportService` rozszerzony o zaleznosc `OrdersRepository`,
|
||||
- po kazdym upsert zamowienia wpisywany jest log z informacja o nowym imporcie lub re-imporcie,
|
||||
- actor_type: `import`, actor_name: `Allegro`,
|
||||
- bez zmian schematu.
|
||||
- Dodano uniwersalny log aktywnosci zamowien:
|
||||
- nowa tabela `order_activity_log` (migracja `20260305_000030_create_order_activity_log_table.sql`),
|
||||
- nowe metody w `OrdersRepository`: `recordActivity()`, `recordStatusChange()`, `updateOrderStatus()`,
|
||||
- zakladka `Historia zmian` w szczegolow zamowienia wyswietla tabele z logiem aktywnosci (data, typ, opis, wykonawca),
|
||||
- typy zdarzen: zmiana statusu, platnosc, faktura, przesylka, wiadomosc, dokument, import, notatka,
|
||||
- kolorowe badge'e typow zdarzen,
|
||||
- historia statusow w zakladce `Szczegoly` pokazuje teraz nazwy statusow zamiast surowych kodow.
|
||||
|
||||
## 2026-03-04
|
||||
- Poprawiono kolumne `Data zamowienia` na liscie zamowien:
|
||||
- wartosc jest liczona fallbackiem `orders.ordered_at -> orders.source_created_at -> orders.source_updated_at -> orders.fetched_at`,
|
||||
|
||||
21
DOCS/todo.md
21
DOCS/todo.md
@@ -1,10 +1,15 @@
|
||||
1. [] Na liście zamówień powiększenie zdjęcia produktu na hover nie na onclick, wtedy to nie może być modal zamykany X
|
||||
2. [] Zmiana statusu rejestrowana w Historii zmian zamĂłwienia
|
||||
3. [] Pobranie zamĂłwienia rejestrowane w histori zmian zamĂłwienia
|
||||
1. [x] Na liście zamówień powiększenie zdjęcia produktu na hover nie na onclick, wtedy to nie może być modal zamykany X
|
||||
2. [x] Dodać rejestrację historii zamówień, i zmiana statusu rejestrowana w Historii zmian zamĂłwienia
|
||||
3. [x] Pobranie zamĂłwienia rejestrowane w histori zmian zamĂłwienia
|
||||
4. [x] Przy imporcie zamówień musi być pobierania forma wysyłki.
|
||||
5. [] W szczególach zamówienia dorobić opcję zmiany statusu.
|
||||
6. [] W szczególach zamówienia 2 razy wyświetla się ID zamówienai z allegro, np: 008d3d60-1743-11f1-b15c-fdb4f87ccfc6
|
||||
7. [] Przy imporcie z allegro liczba przesyłek jest 0.
|
||||
8. [] Kolumna LP w szczególach zamówienia jest zbyt szeroka.
|
||||
5. [x] W szczególach zamówienia dorobić opcję zmiany statusu.
|
||||
6. [x] W szczególach zamówienia 2 razy wyświetla się ID zamówienai z allegro, np: 008d3d60-1743-11f1-b15c-fdb4f87ccfc6
|
||||
7. [x] Przy imporcie z allegro liczba przesyłek jest 0.
|
||||
8. [x] Kolumna LP w szczególach zamówienia jest zbyt szeroka.
|
||||
9. [x] Na lisćie zamówień pole po którym jest domyślnie sortowana czyli data zamówienia jest puste.
|
||||
10. [] Na liście zamówień ukryć kolumnę ostatnia zmiana.
|
||||
10. [x] Na liście zamówień ukryć kolumnę ostatnia zmiana.
|
||||
11. [x] W ustawieniach dodać zakładkę Integracja Apaczka. Dodać tam pierwsze ustawienie, czyli klucz API.
|
||||
12. [] synchronizować ręczną zmianę statusu z allegro
|
||||
13. [] W ustawieniach cron https://orderpro.projectpro.pl/settings/cron historia powinna mieć stronicowanie
|
||||
14. [] border inputów, select, textarea, itd zrób troszkę ciemniejszy
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Modules\Cron\AllegroTokenRefreshHandler;
|
||||
use App\Modules\Cron\CronRepository;
|
||||
use App\Modules\Cron\CronRunner;
|
||||
use App\Modules\Orders\OrderImportRepository;
|
||||
use App\Modules\Orders\OrdersRepository;
|
||||
use App\Modules\Settings\AllegroApiClient;
|
||||
use App\Modules\Settings\AllegroIntegrationRepository;
|
||||
use App\Modules\Settings\AllegroOrderImportService;
|
||||
@@ -40,7 +41,8 @@ $orderImportService = new AllegroOrderImportService(
|
||||
$oauthClient,
|
||||
$apiClient,
|
||||
new OrderImportRepository($app->db()),
|
||||
$statusMappingRepository
|
||||
$statusMappingRepository,
|
||||
new OrdersRepository($app->db())
|
||||
);
|
||||
$ordersSyncService = new AllegroOrdersSyncService(
|
||||
$integrationRepository,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS `apaczka_integration_settings` (
|
||||
`id` TINYINT UNSIGNED NOT NULL PRIMARY KEY,
|
||||
`api_key_encrypted` TEXT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO `apaczka_integration_settings` (`id`) VALUES (1)
|
||||
ON DUPLICATE KEY UPDATE `updated_at` = NOW();
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS `order_activity_log` (
|
||||
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`order_id` BIGINT UNSIGNED NOT NULL,
|
||||
`event_type` VARCHAR(32) NOT NULL,
|
||||
`summary` VARCHAR(255) NOT NULL,
|
||||
`details_json` JSON NULL,
|
||||
`actor_type` VARCHAR(16) NOT NULL DEFAULT 'system',
|
||||
`actor_name` VARCHAR(128) NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY `order_activity_log_order_created_idx` (`order_id`, `created_at`),
|
||||
KEY `order_activity_log_event_type_idx` (`event_type`),
|
||||
CONSTRAINT `order_activity_log_order_fk`
|
||||
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Rozdzielenie credentials Allegro per environment (sandbox / production).
|
||||
-- Dotychczas jeden wiersz (id=1) przechowywał dane obu środowisk.
|
||||
-- Po migracji: po jednym wierszu na environment, UNIQUE KEY na environment.
|
||||
-- Repository odpytuje wiersze po `WHERE environment = :env` zamiast `WHERE id = 1`.
|
||||
|
||||
-- 1. Zmien PK na AUTO_INCREMENT, dodaj UNIQUE KEY na environment
|
||||
ALTER TABLE allegro_integration_settings
|
||||
MODIFY id TINYINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
ADD UNIQUE KEY allegro_integration_settings_env_unique (environment);
|
||||
|
||||
-- 2. Wstaw brakujący wiersz dla drugiego środowiska (puste credentials)
|
||||
INSERT INTO allegro_integration_settings (environment, orders_fetch_enabled, created_at, updated_at)
|
||||
SELECT
|
||||
CASE WHEN environment = 'sandbox' THEN 'production' ELSE 'sandbox' END,
|
||||
0,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM allegro_integration_settings
|
||||
WHERE id = 1
|
||||
ON DUPLICATE KEY UPDATE updated_at = VALUES(updated_at);
|
||||
|
||||
-- 3. Zapisz aktywne srodowisko w app_settings (na podstawie istniejacego wiersza)
|
||||
INSERT INTO app_settings (setting_key, setting_value, updated_at)
|
||||
SELECT 'allegro_active_environment', environment, NOW()
|
||||
FROM allegro_integration_settings WHERE id = 1
|
||||
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = NOW();
|
||||
@@ -0,0 +1,24 @@
|
||||
CREATE TABLE IF NOT EXISTS company_settings (
|
||||
id TINYINT UNSIGNED NOT NULL DEFAULT 1 PRIMARY KEY,
|
||||
company_name VARCHAR(200) NULL,
|
||||
person_name VARCHAR(200) NULL,
|
||||
street VARCHAR(200) NULL,
|
||||
city VARCHAR(128) NULL,
|
||||
postal_code VARCHAR(16) NULL,
|
||||
country_code CHAR(2) NOT NULL DEFAULT 'PL',
|
||||
phone VARCHAR(64) NULL,
|
||||
email VARCHAR(128) NULL,
|
||||
tax_number VARCHAR(64) NULL,
|
||||
bank_account VARCHAR(64) NULL,
|
||||
bank_owner_name VARCHAR(200) NULL,
|
||||
default_package_length_cm DECIMAL(8,1) NOT NULL DEFAULT 25.0,
|
||||
default_package_width_cm DECIMAL(8,1) NOT NULL DEFAULT 20.0,
|
||||
default_package_height_cm DECIMAL(8,1) NOT NULL DEFAULT 8.0,
|
||||
default_package_weight_kg DECIMAL(8,3) NOT NULL DEFAULT 1.000,
|
||||
default_label_format VARCHAR(8) NOT NULL DEFAULT 'PDF',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO company_settings (id) VALUES (1)
|
||||
ON DUPLICATE KEY UPDATE updated_at = VALUES(updated_at);
|
||||
@@ -0,0 +1,37 @@
|
||||
CREATE TABLE IF NOT EXISTS shipment_packages (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'allegro_wza',
|
||||
delivery_method_id VARCHAR(128) NULL,
|
||||
credentials_id VARCHAR(128) NULL,
|
||||
command_id VARCHAR(64) NULL,
|
||||
shipment_id VARCHAR(64) NULL,
|
||||
tracking_number VARCHAR(128) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'draft',
|
||||
carrier_id VARCHAR(64) NULL,
|
||||
package_type VARCHAR(16) NOT NULL DEFAULT 'PACKAGE',
|
||||
weight_kg DECIMAL(8,3) NULL,
|
||||
length_cm DECIMAL(8,1) NULL,
|
||||
width_cm DECIMAL(8,1) NULL,
|
||||
height_cm DECIMAL(8,1) NULL,
|
||||
insurance_amount DECIMAL(12,2) NULL,
|
||||
insurance_currency CHAR(3) NULL,
|
||||
cod_amount DECIMAL(12,2) NULL,
|
||||
cod_currency CHAR(3) NULL,
|
||||
label_format VARCHAR(8) NOT NULL DEFAULT 'PDF',
|
||||
label_path VARCHAR(512) NULL,
|
||||
receiver_point_id VARCHAR(64) NULL,
|
||||
sender_point_id VARCHAR(64) NULL,
|
||||
reference_number VARCHAR(128) NULL,
|
||||
error_message TEXT NULL,
|
||||
payload_json JSON NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY shipment_packages_order_idx (order_id),
|
||||
KEY shipment_packages_status_idx (status),
|
||||
KEY shipment_packages_tracking_idx (tracking_number),
|
||||
KEY shipment_packages_command_idx (command_id),
|
||||
CONSTRAINT shipment_packages_order_fk
|
||||
FOREIGN KEY (order_id) REFERENCES orders(id)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS allegro_delivery_method_mappings (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
order_delivery_method VARCHAR(200) NOT NULL,
|
||||
allegro_delivery_method_id VARCHAR(128) NOT NULL,
|
||||
allegro_credentials_id VARCHAR(128) NULL,
|
||||
allegro_carrier_id VARCHAR(128) NULL,
|
||||
allegro_service_name VARCHAR(255) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY allegro_dm_mapping_unique (order_delivery_method)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE IF NOT EXISTS `inpost_integration_settings` (
|
||||
`id` TINYINT UNSIGNED NOT NULL PRIMARY KEY,
|
||||
`api_token_encrypted` TEXT NULL,
|
||||
`organization_id` VARCHAR(50) NULL,
|
||||
`environment` ENUM('sandbox', 'production') NOT NULL DEFAULT 'sandbox',
|
||||
`default_dispatch_method` ENUM('pop', 'parcel_locker', 'courier') NOT NULL DEFAULT 'pop',
|
||||
`default_dispatch_point` VARCHAR(50) NULL,
|
||||
`default_insurance` DECIMAL(10, 2) NULL,
|
||||
`default_locker_size` ENUM('small', 'medium', 'large') NOT NULL DEFAULT 'small',
|
||||
`default_courier_length` SMALLINT UNSIGNED NULL DEFAULT 20,
|
||||
`default_courier_width` SMALLINT UNSIGNED NULL DEFAULT 15,
|
||||
`default_courier_height` SMALLINT UNSIGNED NULL DEFAULT 8,
|
||||
`label_format` ENUM('Pdf', 'Zpl', 'Epl') NOT NULL DEFAULT 'Pdf',
|
||||
`weekend_delivery` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`auto_insurance_value` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`multi_parcel` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO `inpost_integration_settings` (`id`) VALUES (1)
|
||||
ON DUPLICATE KEY UPDATE `updated_at` = NOW();
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `allegro_delivery_method_mappings`
|
||||
ADD COLUMN `carrier` VARCHAR(50) NOT NULL DEFAULT 'allegro' AFTER `order_delivery_method`;
|
||||
File diff suppressed because one or more lines are too long
@@ -29,6 +29,9 @@ return [
|
||||
'settings' => 'Ustawienia',
|
||||
'statuses' => 'Statusy',
|
||||
'allegro' => 'Integracje Allegro',
|
||||
'apaczka' => 'Integracja Apaczka',
|
||||
'inpost' => 'Integracja InPost',
|
||||
'company' => 'Dane firmy',
|
||||
],
|
||||
'marketplace' => [
|
||||
'title' => 'Marketplace',
|
||||
@@ -159,6 +162,37 @@ return [
|
||||
'send_date' => 'Data wysylki',
|
||||
'shipments_count' => 'Liczba przesylek',
|
||||
],
|
||||
'status_change' => [
|
||||
'placeholder' => '-- zmien status --',
|
||||
'save' => 'Zmien',
|
||||
'success' => 'Status zamowienia zostal zmieniony.',
|
||||
'failed' => 'Nie udalo sie zmienic statusu zamowienia.',
|
||||
'status_required' => 'Wybierz nowy status zamowienia.',
|
||||
],
|
||||
'activity' => [
|
||||
'date' => 'Data',
|
||||
'type' => 'Typ zdarzenia',
|
||||
'summary' => 'Opis',
|
||||
'actor' => 'Wykonawca',
|
||||
'empty' => 'Brak zarejestrowanych zdarzen.',
|
||||
'types' => [
|
||||
'status_change' => 'Zmiana statusu',
|
||||
'payment' => 'Platnosc',
|
||||
'invoice' => 'Faktura',
|
||||
'shipment' => 'Przesylka',
|
||||
'message' => 'Wiadomosc',
|
||||
'document' => 'Dokument',
|
||||
'import' => 'Import',
|
||||
'note' => 'Notatka',
|
||||
],
|
||||
'actors' => [
|
||||
'system' => 'System',
|
||||
'user' => 'Uzytkownik',
|
||||
'import' => 'Import',
|
||||
'api' => 'API',
|
||||
'sync' => 'Synchronizacja',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'users' => [
|
||||
@@ -533,6 +567,76 @@ return [
|
||||
'status_reorder_failed' => 'Nie udalo sie zapisac kolejnosci statusow.',
|
||||
],
|
||||
],
|
||||
'apaczka' => [
|
||||
'title' => 'Integracja Apaczka',
|
||||
'description' => 'Konfiguracja polaczenia z API Apaczka do obslugi przesylek.',
|
||||
'config' => [
|
||||
'title' => 'Konfiguracja API',
|
||||
],
|
||||
'fields' => [
|
||||
'api_key' => 'Klucz API',
|
||||
],
|
||||
'api_key' => [
|
||||
'saved' => 'Klucz API jest zapisany. Pozostaw pole puste, aby nie zmieniac.',
|
||||
'missing' => 'Brak zapisanego klucza API.',
|
||||
],
|
||||
'actions' => [
|
||||
'save' => 'Zapisz ustawienia Apaczka',
|
||||
],
|
||||
'validation' => [
|
||||
'api_key_required' => 'Podaj klucz API Apaczka.',
|
||||
],
|
||||
'flash' => [
|
||||
'saved' => 'Ustawienia Apaczka zostaly zapisane.',
|
||||
'save_failed' => 'Nie udalo sie zapisac ustawien Apaczka.',
|
||||
],
|
||||
],
|
||||
'inpost' => [
|
||||
'title' => 'Integracja InPost',
|
||||
'description' => 'Konfiguracja polaczenia z API InPost ShipX do obslugi przesylek.',
|
||||
'config' => [
|
||||
'title' => 'Konfiguracja API',
|
||||
],
|
||||
'sections' => [
|
||||
'dispatch' => 'Sposob nadania',
|
||||
'locker' => 'Paczkomaty',
|
||||
'courier' => 'Domyslne wymiary przesylek kurierskich',
|
||||
'other' => 'Pozostale ustawienia',
|
||||
],
|
||||
'fields' => [
|
||||
'api_token' => 'Klucz API (token)',
|
||||
'organization_id' => 'Identyfikator organizacji',
|
||||
'environment' => 'Srodowisko',
|
||||
'default_dispatch_method' => 'Domyslny sposob nadania',
|
||||
'default_dispatch_point' => 'Domyslny punkt nadania',
|
||||
'default_insurance' => 'Domyslne ubezpieczenie',
|
||||
'insurance_placeholder' => 'Brak (opcjonalne)',
|
||||
'default_locker_size' => 'Domyslny rozmiar paczki',
|
||||
'courier_length' => 'Dlugosc',
|
||||
'courier_width' => 'Szerokosc',
|
||||
'courier_height' => 'Wysokosc',
|
||||
'label_format' => 'Typ etykiety',
|
||||
'weekend_delivery' => 'Paczka weekendowa',
|
||||
'auto_insurance_value' => 'Automatycznie uzupelniaj wartosc ubezpieczenia',
|
||||
'multi_parcel' => 'Obsluga multi-paczek',
|
||||
],
|
||||
'dispatch_methods' => [
|
||||
'pop' => 'Punkt nadania (POP)',
|
||||
'parcel_locker' => 'Paczkomat',
|
||||
'courier' => 'Kurier',
|
||||
],
|
||||
'api_token' => [
|
||||
'saved' => 'Klucz API jest zapisany. Pozostaw pole puste, aby nie zmieniac.',
|
||||
'missing' => 'Brak zapisanego klucza API.',
|
||||
],
|
||||
'actions' => [
|
||||
'save' => 'Zapisz ustawienia InPost',
|
||||
],
|
||||
'flash' => [
|
||||
'saved' => 'Ustawienia InPost zostaly zapisane.',
|
||||
'save_failed' => 'Nie udalo sie zapisac ustawien InPost.',
|
||||
],
|
||||
],
|
||||
'allegro' => [
|
||||
'title' => 'Integracja Allegro',
|
||||
'description' => 'Konfiguracja OAuth2 i pobierania zamowien z Allegro.',
|
||||
@@ -541,6 +645,7 @@ return [
|
||||
'integration' => 'Integracja',
|
||||
'statuses' => 'Statusy',
|
||||
'settings' => 'Ustawienia',
|
||||
'delivery' => 'Formy dostawy',
|
||||
],
|
||||
'callback' => [
|
||||
'title' => 'Redirect URI do Allegro',
|
||||
@@ -551,6 +656,7 @@ return [
|
||||
],
|
||||
'fields' => [
|
||||
'environment' => 'Srodowisko',
|
||||
'environment_hint' => 'Zmiana srodowiska przelacza na osobne dane logowania (Client ID, Secret, tokeny).',
|
||||
'client_id' => 'Client ID',
|
||||
'client_secret' => 'Client Secret',
|
||||
'redirect_uri' => 'Redirect URI',
|
||||
@@ -636,6 +742,27 @@ return [
|
||||
'sync_failed' => 'Nie udalo sie pobrac statusow z Allegro.',
|
||||
],
|
||||
],
|
||||
'delivery' => [
|
||||
'title' => 'Mapowanie form dostawy',
|
||||
'description' => 'Powiaz formy dostawy z zamowien z uslugami przewoznikow (Allegro, InPost). Mapowanie uzyje sie automatycznie przy tworzeniu przesylki.',
|
||||
'not_connected' => 'Polacz konto Allegro, aby pobrac uslugi dostawy.',
|
||||
'empty_orders' => 'Brak zamowien z forma dostawy. Zaimportuj zamowienia, aby zobaczyc dostepne formy.',
|
||||
'fields' => [
|
||||
'order_method' => 'Forma dostawy z zamowienia',
|
||||
'carrier' => 'Przewoznik',
|
||||
'allegro_service' => 'Usluga dostawy',
|
||||
'search_placeholder' => 'Szukaj uslugi...',
|
||||
'no_mapping' => 'Brak mapowania',
|
||||
'select_carrier_first' => 'Wybierz przewoznika',
|
||||
],
|
||||
'actions' => [
|
||||
'save' => 'Zapisz mapowania',
|
||||
],
|
||||
'flash' => [
|
||||
'saved' => 'Mapowania form dostawy zostaly zapisane.',
|
||||
'save_failed' => 'Nie udalo sie zapisac mapowan form dostawy.',
|
||||
],
|
||||
],
|
||||
'actions' => [
|
||||
'save' => 'Zapisz ustawienia Allegro',
|
||||
'connect' => 'Polacz konto Allegro',
|
||||
@@ -836,6 +963,38 @@ return [
|
||||
'save_failed' => 'Nie udalo sie zapisac ustawien GS1.',
|
||||
],
|
||||
],
|
||||
'company' => [
|
||||
'title' => 'Dane firmy',
|
||||
'description' => 'Adres nadawcy, dane bankowe i domyslne wymiary paczek.',
|
||||
'section_address' => 'Adres nadawcy',
|
||||
'section_bank' => 'Dane bankowe',
|
||||
'section_defaults' => 'Domyslne wymiary paczki',
|
||||
'fields' => [
|
||||
'company_name' => 'Nazwa firmy',
|
||||
'person_name' => 'Imie i nazwisko',
|
||||
'street' => 'Ulica',
|
||||
'postal_code' => 'Kod pocztowy',
|
||||
'city' => 'Miasto',
|
||||
'country_code' => 'Kod kraju',
|
||||
'phone' => 'Telefon',
|
||||
'email' => 'E-mail',
|
||||
'tax_number' => 'NIP',
|
||||
'bank_account' => 'Numer konta',
|
||||
'bank_owner_name' => 'Wlasciciel konta',
|
||||
'length_cm' => 'Dlugosc (cm)',
|
||||
'width_cm' => 'Szerokosc (cm)',
|
||||
'height_cm' => 'Wysokosc (cm)',
|
||||
'weight_kg' => 'Waga (kg)',
|
||||
'label_format' => 'Format etykiety',
|
||||
],
|
||||
'actions' => [
|
||||
'save' => 'Zapisz dane firmy',
|
||||
],
|
||||
'flash' => [
|
||||
'saved' => 'Dane firmy zostaly zapisane.',
|
||||
'save_failed' => 'Nie udalo sie zapisac danych firmy.',
|
||||
],
|
||||
],
|
||||
'products' => [
|
||||
'title' => 'Produkty',
|
||||
'description' => 'Ustawienia generatora SKU dla produktow.',
|
||||
@@ -854,6 +1013,9 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
'shipments' => [
|
||||
'prepare' => [
|
||||
'title' => 'Przygotuj przesylke',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -193,6 +193,14 @@ a {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mt-0 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mt-12 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -305,6 +313,24 @@ a {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-grid-4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -924,6 +950,16 @@ a {
|
||||
font-size: 14px;
|
||||
color: #223247;
|
||||
line-height: 1.25;
|
||||
|
||||
&__delivery {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-bottom: 2px;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.orders-products {
|
||||
@@ -979,15 +1015,56 @@ a {
|
||||
}
|
||||
}
|
||||
|
||||
.orders-image-trigger {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: zoom-in;
|
||||
.orders-image-hover-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.orders-image-hover-popup {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: auto;
|
||||
top: auto;
|
||||
width: 350px;
|
||||
max-height: 350px;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||
border: 1px solid #dfe3ea;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.orders-image-hover-wrap:hover .orders-image-hover-popup {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.activity-type-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
|
||||
&--status_change { background: #dbeafe; color: #1e40af; }
|
||||
&--payment { background: #dcfce7; color: #166534; }
|
||||
&--invoice { background: #fef3c7; color: #92400e; }
|
||||
&--shipment { background: #e0e7ff; color: #3730a3; }
|
||||
&--message { background: #f3e8ff; color: #6b21a8; }
|
||||
&--document { background: #fce7f3; color: #9d174d; }
|
||||
&--import { background: #f1f5f9; color: #475569; }
|
||||
&--note { background: #ecfdf5; color: #065f46; }
|
||||
}
|
||||
|
||||
.text-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.orders-money {
|
||||
@@ -1152,6 +1229,23 @@ a {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.order-status-change {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.order-status-change__form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.order-status-change__select {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.order-details-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
@@ -1613,6 +1707,107 @@ a {
|
||||
border: 1px solid #d9e0ea;
|
||||
}
|
||||
|
||||
.shipment-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.searchable-select {
|
||||
position: relative;
|
||||
|
||||
&__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
min-height: 34px;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 5px solid var(--c-text-muted, #6b7280);
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&--placeholder {
|
||||
color: var(--c-text-muted, #6b7280);
|
||||
}
|
||||
}
|
||||
|
||||
&__dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
z-index: 50;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border: 1px solid var(--c-border);
|
||||
border-top: 0;
|
||||
border-radius: 0 0 8px 8px;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
|
||||
|
||||
&.is-open {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&__search {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
border: none !important;
|
||||
border-bottom: 1px solid var(--c-border) !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__option {
|
||||
padding: 7px 10px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
color: var(--c-text-strong);
|
||||
|
||||
&:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
background: #edf2ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.flash {
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
&--success {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.content-tabs-card {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -1733,6 +1928,10 @@ a {
|
||||
|
||||
.filters-grid,
|
||||
.form-grid,
|
||||
.form-grid-2,
|
||||
.form-grid-3,
|
||||
.form-grid-4,
|
||||
.shipment-grid,
|
||||
.statuses-form,
|
||||
.statuses-inline-form,
|
||||
.table-list-filters,
|
||||
|
||||
@@ -58,6 +58,12 @@
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.btn--sm {
|
||||
min-height: 28px;
|
||||
padding: 3px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn--block {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -90,6 +96,40 @@
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.input {
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 8px;
|
||||
padding: 5px 10px;
|
||||
font: inherit;
|
||||
color: var(--c-text-strong);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.input--sm {
|
||||
min-height: 28px;
|
||||
padding: 3px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.flash {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.flash--success {
|
||||
border: 1px solid #b7ebcf;
|
||||
background: #f0fff6;
|
||||
color: #0f6b39;
|
||||
}
|
||||
|
||||
.flash--error {
|
||||
border: 1px solid #fed7d7;
|
||||
background: #fff5f5;
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
@@ -130,6 +170,11 @@
|
||||
.table-wrap {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
|
||||
&--visible {
|
||||
overflow: visible !important;
|
||||
overflow-x: visible !important;
|
||||
}
|
||||
}
|
||||
|
||||
.table {
|
||||
@@ -155,6 +200,12 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table--details th:first-child,
|
||||
.table--details td:first-child {
|
||||
width: 36px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -46,6 +46,15 @@
|
||||
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'allegro' ? ' is-active' : '' ?>" href="/settings/integrations/allegro">
|
||||
<?= $e($t('navigation.allegro')) ?>
|
||||
</a>
|
||||
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'apaczka' ? ' is-active' : '' ?>" href="/settings/integrations/apaczka">
|
||||
<?= $e($t('navigation.apaczka')) ?>
|
||||
</a>
|
||||
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'inpost' ? ' is-active' : '' ?>" href="/settings/integrations/inpost">
|
||||
<?= $e($t('navigation.inpost')) ?>
|
||||
</a>
|
||||
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'company' ? ' is-active' : '' ?>" href="/settings/company">
|
||||
<?= $e($t('navigation.company')) ?>
|
||||
</a>
|
||||
</div>
|
||||
</details>
|
||||
</nav>
|
||||
|
||||
@@ -37,48 +37,32 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="modal-backdrop" data-orders-image-modal hidden>
|
||||
<div class="modal modal--image-preview" role="dialog" aria-modal="true" aria-label="Podglad zdjecia produktu">
|
||||
<div class="modal__header">
|
||||
<h3>Podglad zdjecia</h3>
|
||||
<button type="button" class="btn btn--secondary" data-orders-image-close>Zamknij</button>
|
||||
</div>
|
||||
<div class="modal__body">
|
||||
<img src="" alt="" class="product-image-preview__img" data-orders-image-preview>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var modal = document.querySelector('[data-orders-image-modal]');
|
||||
var preview = document.querySelector('[data-orders-image-preview]');
|
||||
if (!modal || !preview) return;
|
||||
var POPUP_GAP = 12;
|
||||
|
||||
function closeModal() {
|
||||
modal.setAttribute('hidden', 'hidden');
|
||||
preview.setAttribute('src', '');
|
||||
}
|
||||
document.addEventListener('mouseenter', function (e) {
|
||||
var wrap = e.target.closest('.orders-image-hover-wrap');
|
||||
if (!wrap) return;
|
||||
var popup = wrap.querySelector('.orders-image-hover-popup');
|
||||
if (!popup) return;
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var trigger = event.target.closest('.js-order-img-open');
|
||||
if (trigger) {
|
||||
var imageUrl = trigger.getAttribute('data-image-url') || '';
|
||||
if (imageUrl === '') return;
|
||||
preview.setAttribute('src', imageUrl);
|
||||
modal.removeAttribute('hidden');
|
||||
return;
|
||||
var rect = wrap.getBoundingClientRect();
|
||||
var pw = 350;
|
||||
var ph = 350;
|
||||
|
||||
var left = rect.right + POPUP_GAP;
|
||||
if (left + pw > window.innerWidth) {
|
||||
left = rect.left - pw - POPUP_GAP;
|
||||
}
|
||||
|
||||
if (event.target.matches('[data-orders-image-close]') || event.target === modal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
var top = rect.top + rect.height / 2 - ph / 2;
|
||||
if (top < 4) top = 4;
|
||||
if (top + ph > window.innerHeight - 4) top = window.innerHeight - 4 - ph;
|
||||
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape' && !modal.hasAttribute('hidden')) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
popup.style.left = left + 'px';
|
||||
popup.style.top = top + 'px';
|
||||
}, true);
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
@@ -7,8 +7,13 @@ $shipmentsList = is_array($shipments ?? null) ? $shipments : [];
|
||||
$documentsList = is_array($documents ?? null) ? $documents : [];
|
||||
$notesList = is_array($notes ?? null) ? $notes : [];
|
||||
$historyList = is_array($history ?? null) ? $history : [];
|
||||
$activityLogList = is_array($activityLog ?? null) ? $activityLog : [];
|
||||
$statusPanelList = is_array($statusPanel ?? null) ? $statusPanel : [];
|
||||
$statusPanelTitle = 'Statusy';
|
||||
$allStatusesList = is_array($allStatuses ?? null) ? $allStatuses : [];
|
||||
$currentStatusCodeValue = (string) ($currentStatusCode ?? '');
|
||||
$flashSuccessMsg = (string) ($flashSuccess ?? '');
|
||||
$flashErrorMsg = (string) ($flashError ?? '');
|
||||
|
||||
$addressByType = [
|
||||
'customer' => null,
|
||||
@@ -33,25 +38,63 @@ foreach ($addressesList as $address) {
|
||||
<a href="/orders/list" class="order-back-link">← <?= $e($t('navigation.orders_list')) ?></a>
|
||||
<h2 class="section-title mt-12"><?= $e($t('orders.details.title')) ?> #<?= $e((string) ($orderId ?? 0)) ?></h2>
|
||||
<div class="order-details-sub mt-12">
|
||||
<span><?= $e((string) ($orderRow['source_order_id'] ?? '')) ?></span>
|
||||
<span><?= $e((string) ($orderRow['external_order_id'] ?? '')) ?></span>
|
||||
<span><?= $e(ucfirst((string) ($orderRow['source'] ?? ''))) ?> <?= $e((string) ($orderRow['external_order_id'] ?? '')) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="order-details-actions">
|
||||
<button type="button" class="btn btn--secondary">Strefa klienta</button>
|
||||
<button type="button" class="btn btn--secondary">Przygotuj przesylke</button>
|
||||
<a href="/orders/<?= $e((string) ($orderId ?? 0)) ?>/shipment/prepare" class="btn btn--secondary">Przygotuj przesylke</a>
|
||||
<button type="button" class="btn btn--secondary">Platnosc</button>
|
||||
<button type="button" class="btn btn--secondary">Drukuj</button>
|
||||
<button type="button" class="btn btn--primary">Pakuj</button>
|
||||
<button type="button" class="btn btn--secondary">Edytuj</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="order-details-pill mt-12"><?= $e((string) ($statusLabel ?? '-')) ?></div>
|
||||
<?php if ($flashSuccessMsg !== ''): ?>
|
||||
<div class="flash flash--success mt-12"><?= $e($flashSuccessMsg) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($flashErrorMsg !== ''): ?>
|
||||
<div class="flash flash--error mt-12"><?= $e($flashErrorMsg) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="order-status-change mt-12">
|
||||
<span class="order-details-pill"><?= $e((string) ($statusLabel ?? '-')) ?></span>
|
||||
<?php if ($allStatusesList !== []): ?>
|
||||
<form method="post" action="/orders/<?= $e((string) ($orderId ?? 0)) ?>/status" class="order-status-change__form">
|
||||
<input type="hidden" name="_csrf_token" value="<?= $e((string) ($csrfToken ?? '')) ?>">
|
||||
<select name="new_status" class="input input--sm order-status-change__select">
|
||||
<option value=""><?= $e($t('orders.details.status_change.placeholder')) ?></option>
|
||||
<?php
|
||||
$lastGroup = null;
|
||||
foreach ($allStatusesList as $statusOption):
|
||||
$optCode = (string) ($statusOption['code'] ?? '');
|
||||
$optName = (string) ($statusOption['name'] ?? $optCode);
|
||||
$optGroup = (string) ($statusOption['group'] ?? '');
|
||||
if ($optGroup !== $lastGroup):
|
||||
if ($lastGroup !== null): ?>
|
||||
</optgroup>
|
||||
<?php endif;
|
||||
if ($optGroup !== ''): ?>
|
||||
<optgroup label="<?= $e($optGroup) ?>">
|
||||
<?php endif;
|
||||
$lastGroup = $optGroup;
|
||||
endif;
|
||||
?>
|
||||
<option value="<?= $e($optCode) ?>"<?= $optCode === $currentStatusCodeValue ? ' selected' : '' ?>><?= $e($optName) ?></option>
|
||||
<?php endforeach;
|
||||
if ($lastGroup !== null && $lastGroup !== ''): ?>
|
||||
</optgroup>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
<button type="submit" class="btn btn--primary btn--sm"><?= $e($t('orders.details.status_change.save')) ?></button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card mt-16 order-details-tabs">
|
||||
<button type="button" class="order-details-tab is-active" data-order-tab-target="details"><?= $e($t('orders.details.tabs.details')) ?></button>
|
||||
<button type="button" class="order-details-tab" data-order-tab-target="history"><?= $e($t('orders.details.tabs.history')) ?> (<?= $e((string) count($historyList)) ?>)</button>
|
||||
<button type="button" class="order-details-tab" data-order-tab-target="history"><?= $e($t('orders.details.tabs.history')) ?> (<?= $e((string) count($activityLogList)) ?>)</button>
|
||||
<button type="button" class="order-details-tab" data-order-tab-target="shipments"><?= $e($t('orders.details.tabs.shipments')) ?> (<?= $e((string) count($shipmentsList)) ?>)</button>
|
||||
<button type="button" class="order-details-tab" data-order-tab-target="payments"><?= $e($t('orders.details.tabs.payments')) ?> (<?= $e((string) count($paymentsList)) ?>)</button>
|
||||
<button type="button" class="order-details-tab" data-order-tab-target="documents"><?= $e($t('orders.details.tabs.documents')) ?> (<?= $e((string) count($documentsList)) ?>)</button>
|
||||
@@ -183,8 +226,14 @@ foreach ($addressesList as $address) {
|
||||
<?php endif; ?>
|
||||
<?php foreach ($historyList as $event): ?>
|
||||
<div class="order-event">
|
||||
<div class="order-event__head"><?= $e((string) ($event['changed_at'] ?? '')) ?></div>
|
||||
<div class="order-event__body"><?= $e((string) ($event['from_status_id'] ?? '-')) ?> -> <?= $e((string) ($event['to_status_id'] ?? '-')) ?></div>
|
||||
<div class="order-event__head">
|
||||
<?= $e((string) ($event['changed_at'] ?? '')) ?>
|
||||
<?php $changeSource = (string) ($event['change_source'] ?? ''); ?>
|
||||
<?php if ($changeSource !== ''): ?>
|
||||
<span class="muted">(<?= $e($changeSource) ?>)</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="order-event__body"><?= $e((string) ($event['from_label'] ?? '-')) ?> → <?= $e((string) ($event['to_label'] ?? '-')) ?></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
@@ -195,7 +244,50 @@ foreach ($addressesList as $address) {
|
||||
<div class="order-tab-panel" data-order-tab-panel="history">
|
||||
<section class="card mt-16">
|
||||
<h3 class="section-title"><?= $e($t('orders.details.tabs.history')) ?></h3>
|
||||
<div class="order-empty-placeholder mt-12"></div>
|
||||
<div class="table-wrap mt-12">
|
||||
<table class="table table--details">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?= $e($t('orders.details.activity.date')) ?></th>
|
||||
<th><?= $e($t('orders.details.activity.type')) ?></th>
|
||||
<th><?= $e($t('orders.details.activity.summary')) ?></th>
|
||||
<th><?= $e($t('orders.details.activity.actor')) ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ($activityLogList === []): ?>
|
||||
<tr><td colspan="4" class="muted"><?= $e($t('orders.details.activity.empty')) ?></td></tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($activityLogList as $activity): ?>
|
||||
<?php
|
||||
$eventType = (string) ($activity['event_type'] ?? '');
|
||||
$eventTypeKey = 'orders.details.activity.types.' . $eventType;
|
||||
$eventTypeLabel = $t($eventTypeKey);
|
||||
if ($eventTypeLabel === $eventTypeKey) {
|
||||
$eventTypeLabel = $eventType;
|
||||
}
|
||||
$actorType = (string) ($activity['actor_type'] ?? 'system');
|
||||
$actorName = trim((string) ($activity['actor_name'] ?? ''));
|
||||
if ($actorName !== '') {
|
||||
$actorLabel = $actorName;
|
||||
} else {
|
||||
$actorKey = 'orders.details.activity.actors.' . $actorType;
|
||||
$actorLabel = $t($actorKey);
|
||||
if ($actorLabel === $actorKey) {
|
||||
$actorLabel = $actorType;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<tr>
|
||||
<td class="text-nowrap"><?= $e((string) ($activity['created_at'] ?? '')) ?></td>
|
||||
<td><span class="activity-type-badge activity-type-badge--<?= $e($eventType) ?>"><?= $e($eventTypeLabel) ?></span></td>
|
||||
<td><?= $e((string) ($activity['summary'] ?? '')) ?></td>
|
||||
<td class="muted"><?= $e($actorLabel) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ $orderproStatuses = is_array($orderproStatuses ?? null) ? $orderproStatuses : []
|
||||
<button type="button" class="content-tab-btn<?= $activeTab === 'settings' ? ' is-active' : '' ?>" data-tab-target="allegro-tab-settings">
|
||||
<?= $e($t('settings.allegro.tabs.settings')) ?>
|
||||
</button>
|
||||
<button type="button" class="content-tab-btn<?= $activeTab === 'delivery' ? ' is-active' : '' ?>" data-tab-target="allegro-tab-delivery">
|
||||
<?= $e($t('settings.allegro.tabs.delivery')) ?>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="content-tab-panel<?= $activeTab === 'integration' ? ' is-active' : '' ?>" data-tab-panel="allegro-tab-integration">
|
||||
@@ -63,10 +66,11 @@ $orderproStatuses = is_array($orderproStatuses ?? null) ? $orderproStatuses : []
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.allegro.fields.environment')) ?></span>
|
||||
<select class="form-control" name="environment">
|
||||
<select class="form-control" name="environment" id="allegro-env-select">
|
||||
<option value="sandbox"<?= $environment === 'sandbox' ? ' selected' : '' ?>><?= $e($t('settings.allegro.environment.sandbox')) ?></option>
|
||||
<option value="production"<?= $environment === 'production' ? ' selected' : '' ?>><?= $e($t('settings.allegro.environment.production')) ?></option>
|
||||
</select>
|
||||
<span class="muted"><?= $e($t('settings.allegro.fields.environment_hint')) ?></span>
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
@@ -283,6 +287,125 @@ $orderproStatuses = is_array($orderproStatuses ?? null) ? $orderproStatuses : []
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
$dmMappings = is_array($deliveryMappings ?? null) ? $deliveryMappings : [];
|
||||
$dmOrderMethods = is_array($orderDeliveryMethods ?? null) ? $orderDeliveryMethods : [];
|
||||
$dmAllegroServices = is_array($allegroDeliveryServices ?? null) ? $allegroDeliveryServices : [];
|
||||
$dmInpostServices = is_array($inpostDeliveryServices ?? null) ? $inpostDeliveryServices : [];
|
||||
$dmServicesError = (string) ($allegroDeliveryServicesError ?? '');
|
||||
$dmMappingsByMethod = [];
|
||||
foreach ($dmMappings as $dm) {
|
||||
$dmMappingsByMethod[trim((string) ($dm['order_delivery_method'] ?? ''))] = $dm;
|
||||
}
|
||||
?>
|
||||
<div class="content-tab-panel<?= $activeTab === 'delivery' ? ' is-active' : '' ?>" data-tab-panel="allegro-tab-delivery">
|
||||
<section class="mt-16">
|
||||
<h3 class="section-title"><?= $e($t('settings.allegro.delivery.title')) ?></h3>
|
||||
<p class="muted mt-12"><?= $e($t('settings.allegro.delivery.description')) ?></p>
|
||||
|
||||
<?php if ($dmServicesError !== ''): ?>
|
||||
<div class="alert alert--danger mt-12"><?= $e($dmServicesError) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($dmOrderMethods === []): ?>
|
||||
<p class="muted mt-12"><?= $e($t('settings.allegro.delivery.empty_orders')) ?></p>
|
||||
<?php else: ?>
|
||||
<form action="/settings/integrations/allegro/delivery/save" method="post">
|
||||
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||
<div class="table-wrap table-wrap--visible mt-12">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?= $e($t('settings.allegro.delivery.fields.order_method')) ?></th>
|
||||
<th><?= $e($t('settings.allegro.delivery.fields.carrier')) ?></th>
|
||||
<th><?= $e($t('settings.allegro.delivery.fields.allegro_service')) ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($dmOrderMethods as $rowIdx => $orderMethod): ?>
|
||||
<?php
|
||||
$currentMapping = $dmMappingsByMethod[$orderMethod] ?? null;
|
||||
$currentCarrier = $currentMapping !== null ? trim((string) ($currentMapping['carrier'] ?? 'allegro')) : '';
|
||||
$currentAllegroId = $currentMapping !== null ? trim((string) ($currentMapping['allegro_delivery_method_id'] ?? '')) : '';
|
||||
$currentServiceName = $currentMapping !== null ? trim((string) ($currentMapping['allegro_service_name'] ?? '')) : '';
|
||||
?>
|
||||
<tr data-dm-row="<?= $rowIdx ?>">
|
||||
<td>
|
||||
<strong><?= $e($orderMethod) ?></strong>
|
||||
<input type="hidden" name="order_delivery_method[]" value="<?= $e($orderMethod) ?>">
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-control dm-carrier-select" name="carrier[]" data-row="<?= $rowIdx ?>">
|
||||
<option value="">-- <?= $e($t('settings.allegro.delivery.fields.no_mapping')) ?> --</option>
|
||||
<option value="allegro"<?= $currentCarrier === 'allegro' && $currentAllegroId !== '' ? ' selected' : '' ?>>Allegro</option>
|
||||
<option value="inpost"<?= $currentCarrier === 'inpost' ? ' selected' : '' ?>>InPost</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<div class="dm-service-wrap" data-row="<?= $rowIdx ?>">
|
||||
<input type="hidden" name="allegro_delivery_method_id[]" class="dm-hidden-method-id" value="<?= $e($currentAllegroId) ?>">
|
||||
<input type="hidden" name="allegro_credentials_id[]" class="dm-hidden-credentials-id" value="<?= $e(trim((string) ($currentMapping['allegro_credentials_id'] ?? ''))) ?>">
|
||||
<input type="hidden" name="allegro_carrier_id[]" class="dm-hidden-carrier-id" value="<?= $e(trim((string) ($currentMapping['allegro_carrier_id'] ?? ''))) ?>">
|
||||
<input type="hidden" name="allegro_service_name[]" class="dm-hidden-service-name" value="<?= $e($currentServiceName) ?>">
|
||||
|
||||
<?php // Allegro searchable select ?>
|
||||
<div class="dm-allegro-panel dm-searchable-select" data-current-id="<?= $e($currentCarrier === 'allegro' ? $currentAllegroId : '') ?>" data-current-name="<?= $e($currentCarrier === 'allegro' ? $currentServiceName : '') ?>" style="<?= $currentCarrier !== 'allegro' || $currentAllegroId === '' && $currentCarrier === '' ? 'display:none' : '' ?>">
|
||||
<input type="text" class="form-control dm-search-input" placeholder="<?= $e($t('settings.allegro.delivery.fields.search_placeholder')) ?>" value="<?= $e($currentCarrier === 'allegro' ? $currentServiceName : '') ?>" autocomplete="off">
|
||||
<div class="searchable-select__dropdown dm-dropdown">
|
||||
<div class="searchable-select__option dm-option-clear" data-value="" data-label="" data-credentials-id="" data-carrier-id="">
|
||||
<em class="muted">-- <?= $e($t('settings.allegro.delivery.fields.no_mapping')) ?> --</em>
|
||||
</div>
|
||||
<?php foreach ($dmAllegroServices as $svc): ?>
|
||||
<?php
|
||||
$svcId = is_array($svc['id'] ?? null) ? $svc['id'] : [];
|
||||
$svcMethodId = trim((string) ($svcId['deliveryMethodId'] ?? ''));
|
||||
$svcCredentialsId = trim((string) ($svcId['credentialsId'] ?? ''));
|
||||
$svcName = trim((string) ($svc['name'] ?? ''));
|
||||
$svcCarrierId = trim((string) ($svc['carrierId'] ?? ''));
|
||||
$svcOwner = trim((string) ($svc['owner'] ?? ''));
|
||||
$svcLabel = $svcName . ' (' . $svcOwner . ')';
|
||||
?>
|
||||
<div class="searchable-select__option"
|
||||
data-value="<?= $e($svcMethodId) ?>"
|
||||
data-label="<?= $e($svcLabel) ?>"
|
||||
data-credentials-id="<?= $e($svcCredentialsId) ?>"
|
||||
data-carrier-id="<?= $e($svcCarrierId) ?>"
|
||||
><?= $e($svcName) ?> <span class="muted">(<?= $e($svcOwner) ?>)</span></div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php // InPost simple select ?>
|
||||
<div class="dm-inpost-panel" style="<?= $currentCarrier !== 'inpost' ? 'display:none' : '' ?>">
|
||||
<select class="form-control dm-inpost-select">
|
||||
<option value="">-- <?= $e($t('settings.allegro.delivery.fields.no_mapping')) ?> --</option>
|
||||
<?php foreach ($dmInpostServices as $inSvc): ?>
|
||||
<option value="<?= $e((string) ($inSvc['id'] ?? '')) ?>"<?= $currentCarrier === 'inpost' && $currentAllegroId === (string) ($inSvc['id'] ?? '') ? ' selected' : '' ?>>
|
||||
<?= $e((string) ($inSvc['name'] ?? '')) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<?php // Empty state ?>
|
||||
<div class="dm-empty-panel muted" style="<?= $currentCarrier !== '' ? 'display:none' : ($currentAllegroId !== '' ? 'display:none' : '') ?>">
|
||||
<?= $e($t('settings.allegro.delivery.fields.select_carrier_first')) ?>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="form-actions mt-12">
|
||||
<button type="submit" class="btn btn--primary"><?= $e($t('settings.allegro.delivery.actions.save')) ?></button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
@@ -293,9 +416,29 @@ $orderproStatuses = is_array($orderproStatuses ?? null) ? $orderproStatuses : []
|
||||
return;
|
||||
}
|
||||
|
||||
var tabNameMap = {
|
||||
'allegro-tab-integration': 'integration',
|
||||
'allegro-tab-statuses': 'statuses',
|
||||
'allegro-tab-settings': 'settings',
|
||||
'allegro-tab-delivery': 'delivery'
|
||||
};
|
||||
|
||||
tabs.forEach(function (tab) {
|
||||
tab.addEventListener('click', function () {
|
||||
var target = tab.getAttribute('data-tab-target');
|
||||
var tabName = tabNameMap[target] || 'integration';
|
||||
var url = new URL(window.location.href);
|
||||
var currentTab = url.searchParams.get('tab') || 'integration';
|
||||
url.searchParams.set('tab', tabName);
|
||||
|
||||
// Tabs that need server data require a full reload
|
||||
if (tabName === 'delivery' && currentTab !== 'delivery') {
|
||||
window.location.href = url.toString();
|
||||
return;
|
||||
}
|
||||
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
|
||||
tabs.forEach(function (node) { node.classList.remove('is-active'); });
|
||||
panels.forEach(function (panel) { panel.classList.remove('is-active'); });
|
||||
tab.classList.add('is-active');
|
||||
@@ -306,4 +449,128 @@ $orderproStatuses = is_array($orderproStatuses ?? null) ? $orderproStatuses : []
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
(function () {
|
||||
var envSelect = document.getElementById('allegro-env-select');
|
||||
if (!envSelect) return;
|
||||
envSelect.addEventListener('change', function () {
|
||||
window.location.href = '/settings/integrations/allegro?env=' + encodeURIComponent(envSelect.value);
|
||||
});
|
||||
})();
|
||||
|
||||
(function () {
|
||||
// Carrier switching logic
|
||||
document.querySelectorAll('.dm-carrier-select').forEach(function (carrierSelect) {
|
||||
var rowIdx = carrierSelect.getAttribute('data-row');
|
||||
var serviceWrap = document.querySelector('.dm-service-wrap[data-row="' + rowIdx + '"]');
|
||||
if (!serviceWrap) return;
|
||||
|
||||
var allegroPanel = serviceWrap.querySelector('.dm-allegro-panel');
|
||||
var inpostPanel = serviceWrap.querySelector('.dm-inpost-panel');
|
||||
var emptyPanel = serviceWrap.querySelector('.dm-empty-panel');
|
||||
var hiddenMethodId = serviceWrap.querySelector('.dm-hidden-method-id');
|
||||
var hiddenCredentialsId = serviceWrap.querySelector('.dm-hidden-credentials-id');
|
||||
var hiddenCarrierId = serviceWrap.querySelector('.dm-hidden-carrier-id');
|
||||
var hiddenServiceName = serviceWrap.querySelector('.dm-hidden-service-name');
|
||||
|
||||
function showPanel(carrier) {
|
||||
if (allegroPanel) allegroPanel.style.display = carrier === 'allegro' ? '' : 'none';
|
||||
if (inpostPanel) inpostPanel.style.display = carrier === 'inpost' ? '' : 'none';
|
||||
if (emptyPanel) emptyPanel.style.display = carrier === '' ? '' : 'none';
|
||||
}
|
||||
|
||||
carrierSelect.addEventListener('change', function () {
|
||||
var carrier = carrierSelect.value;
|
||||
showPanel(carrier);
|
||||
// Clear hidden values when switching carrier
|
||||
if (hiddenMethodId) hiddenMethodId.value = '';
|
||||
if (hiddenCredentialsId) hiddenCredentialsId.value = '';
|
||||
if (hiddenCarrierId) hiddenCarrierId.value = '';
|
||||
if (hiddenServiceName) hiddenServiceName.value = '';
|
||||
// Reset Allegro search input
|
||||
var allegroInput = allegroPanel ? allegroPanel.querySelector('.dm-search-input') : null;
|
||||
if (allegroInput) allegroInput.value = '';
|
||||
// Reset InPost select
|
||||
var inpostSelect = inpostPanel ? inpostPanel.querySelector('.dm-inpost-select') : null;
|
||||
if (inpostSelect) inpostSelect.value = '';
|
||||
});
|
||||
|
||||
// InPost select change -> update hidden fields
|
||||
var inpostSelect = inpostPanel ? inpostPanel.querySelector('.dm-inpost-select') : null;
|
||||
if (inpostSelect) {
|
||||
inpostSelect.addEventListener('change', function () {
|
||||
var opt = inpostSelect.options[inpostSelect.selectedIndex];
|
||||
if (hiddenMethodId) hiddenMethodId.value = inpostSelect.value;
|
||||
if (hiddenCredentialsId) hiddenCredentialsId.value = '';
|
||||
if (hiddenCarrierId) hiddenCarrierId.value = '';
|
||||
if (hiddenServiceName) hiddenServiceName.value = opt ? opt.textContent.trim() : '';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Allegro searchable selects
|
||||
document.querySelectorAll('.dm-searchable-select').forEach(function (wrapper) {
|
||||
var searchInput = wrapper.querySelector('.dm-search-input');
|
||||
var dropdown = wrapper.querySelector('.dm-dropdown');
|
||||
var serviceWrap = wrapper.closest('.dm-service-wrap');
|
||||
if (!searchInput || !dropdown || !serviceWrap) return;
|
||||
|
||||
var hiddenMethodId = serviceWrap.querySelector('.dm-hidden-method-id');
|
||||
var hiddenCredentialsId = serviceWrap.querySelector('.dm-hidden-credentials-id');
|
||||
var hiddenCarrierId = serviceWrap.querySelector('.dm-hidden-carrier-id');
|
||||
var hiddenServiceName = serviceWrap.querySelector('.dm-hidden-service-name');
|
||||
|
||||
var options = dropdown.querySelectorAll('.searchable-select__option');
|
||||
wrapper.style.position = 'relative';
|
||||
|
||||
function selectOption(opt) {
|
||||
hiddenMethodId.value = opt.getAttribute('data-value') || '';
|
||||
hiddenCredentialsId.value = opt.getAttribute('data-credentials-id') || '';
|
||||
hiddenCarrierId.value = opt.getAttribute('data-carrier-id') || '';
|
||||
hiddenServiceName.value = opt.getAttribute('data-label') || '';
|
||||
searchInput.value = opt.getAttribute('data-label') || '';
|
||||
dropdown.classList.remove('is-open');
|
||||
options.forEach(function (o) { o.classList.remove('is-selected'); });
|
||||
opt.classList.add('is-selected');
|
||||
}
|
||||
|
||||
function filterOptions(query) {
|
||||
var q = query.toLowerCase().trim();
|
||||
options.forEach(function (opt) {
|
||||
var label = (opt.getAttribute('data-label') || '').toLowerCase();
|
||||
opt.style.display = (q === '' || label.indexOf(q) !== -1) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
searchInput.addEventListener('focus', function () {
|
||||
filterOptions(searchInput.value);
|
||||
dropdown.classList.add('is-open');
|
||||
});
|
||||
|
||||
searchInput.addEventListener('input', function () {
|
||||
filterOptions(searchInput.value);
|
||||
dropdown.classList.add('is-open');
|
||||
});
|
||||
|
||||
options.forEach(function (opt) {
|
||||
opt.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault();
|
||||
selectOption(opt);
|
||||
});
|
||||
});
|
||||
|
||||
searchInput.addEventListener('blur', function () {
|
||||
setTimeout(function () { dropdown.classList.remove('is-open'); }, 150);
|
||||
});
|
||||
|
||||
var currentId = wrapper.getAttribute('data-current-id') || '';
|
||||
if (currentId !== '') {
|
||||
options.forEach(function (opt) {
|
||||
if (opt.getAttribute('data-value') === currentId) {
|
||||
opt.classList.add('is-selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
34
resources/views/settings/apaczka.php
Normal file
34
resources/views/settings/apaczka.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
$integration = is_array($settings ?? null) ? $settings : [];
|
||||
$hasApiKey = (bool) ($integration['has_api_key'] ?? false);
|
||||
?>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="section-title"><?= $e($t('settings.apaczka.title')) ?></h2>
|
||||
<p class="muted mt-12"><?= $e($t('settings.apaczka.description')) ?></p>
|
||||
|
||||
<?php if (!empty($errorMessage)): ?>
|
||||
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($successMessage)): ?>
|
||||
<div class="alert alert--success mt-12" role="status"><?= $e((string) $successMessage) ?></div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="card mt-16">
|
||||
<h3 class="section-title"><?= $e($t('settings.apaczka.config.title')) ?></h3>
|
||||
<form class="statuses-form mt-16" action="/settings/integrations/apaczka/save" method="post" novalidate>
|
||||
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.apaczka.fields.api_key')) ?></span>
|
||||
<input class="form-control" type="password" name="api_key" autocomplete="new-password">
|
||||
<span class="muted"><?= $e($hasApiKey ? $t('settings.apaczka.api_key.saved') : $t('settings.apaczka.api_key.missing')) ?></span>
|
||||
</label>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn--primary"><?= $e($t('settings.apaczka.actions.save')) ?></button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
116
resources/views/settings/company.php
Normal file
116
resources/views/settings/company.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
$s = is_array($settings ?? null) ? $settings : [];
|
||||
?>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="section-title"><?= $e($t('settings.company.title')) ?></h2>
|
||||
<p class="muted mt-12"><?= $e($t('settings.company.description')) ?></p>
|
||||
|
||||
<?php if (!empty($errorMessage)): ?>
|
||||
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($successMessage)): ?>
|
||||
<div class="alert alert--success mt-12" role="status"><?= $e((string) $successMessage) ?></div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="card mt-16">
|
||||
<form action="/settings/company/save" method="post" novalidate>
|
||||
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||
|
||||
<h3 class="section-title"><?= $e($t('settings.company.section_address')) ?></h3>
|
||||
|
||||
<div class="form-grid-2 mt-12">
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.company.fields.company_name')) ?></span>
|
||||
<input class="form-control" type="text" name="company_name" maxlength="200" value="<?= $e((string) ($s['company_name'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.company.fields.person_name')) ?></span>
|
||||
<input class="form-control" type="text" name="person_name" maxlength="200" value="<?= $e((string) ($s['person_name'] ?? '')) ?>">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.company.fields.street')) ?></span>
|
||||
<input class="form-control" type="text" name="street" maxlength="200" value="<?= $e((string) ($s['street'] ?? '')) ?>">
|
||||
</label>
|
||||
|
||||
<div class="form-grid-3 mt-0">
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.company.fields.postal_code')) ?></span>
|
||||
<input class="form-control" type="text" name="postal_code" maxlength="16" value="<?= $e((string) ($s['postal_code'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.company.fields.city')) ?></span>
|
||||
<input class="form-control" type="text" name="city" maxlength="128" value="<?= $e((string) ($s['city'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.company.fields.country_code')) ?></span>
|
||||
<input class="form-control" type="text" name="country_code" maxlength="2" value="<?= $e((string) ($s['country_code'] ?? 'PL')) ?>">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-grid-2">
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.company.fields.phone')) ?></span>
|
||||
<input class="form-control" type="tel" name="phone" maxlength="64" value="<?= $e((string) ($s['phone'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.company.fields.email')) ?></span>
|
||||
<input class="form-control" type="email" name="email" maxlength="128" value="<?= $e((string) ($s['email'] ?? '')) ?>">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.company.fields.tax_number')) ?></span>
|
||||
<input class="form-control" type="text" name="tax_number" maxlength="64" value="<?= $e((string) ($s['tax_number'] ?? '')) ?>">
|
||||
</label>
|
||||
|
||||
<h3 class="section-title mt-16"><?= $e($t('settings.company.section_bank')) ?></h3>
|
||||
|
||||
<div class="form-grid-2 mt-12">
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.company.fields.bank_account')) ?></span>
|
||||
<input class="form-control" type="text" name="bank_account" maxlength="64" value="<?= $e((string) ($s['bank_account'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.company.fields.bank_owner_name')) ?></span>
|
||||
<input class="form-control" type="text" name="bank_owner_name" maxlength="200" value="<?= $e((string) ($s['bank_owner_name'] ?? '')) ?>">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title mt-16"><?= $e($t('settings.company.section_defaults')) ?></h3>
|
||||
|
||||
<div class="form-grid-4 mt-12">
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.company.fields.length_cm')) ?></span>
|
||||
<input class="form-control" type="number" name="default_package_length_cm" step="0.1" min="0.1" value="<?= $e((string) ($s['default_package_length_cm'] ?? '25')) ?>">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.company.fields.width_cm')) ?></span>
|
||||
<input class="form-control" type="number" name="default_package_width_cm" step="0.1" min="0.1" value="<?= $e((string) ($s['default_package_width_cm'] ?? '20')) ?>">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.company.fields.height_cm')) ?></span>
|
||||
<input class="form-control" type="number" name="default_package_height_cm" step="0.1" min="0.1" value="<?= $e((string) ($s['default_package_height_cm'] ?? '8')) ?>">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.company.fields.weight_kg')) ?></span>
|
||||
<input class="form-control" type="number" name="default_package_weight_kg" step="0.001" min="0.001" value="<?= $e((string) ($s['default_package_weight_kg'] ?? '1')) ?>">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.company.fields.label_format')) ?></span>
|
||||
<select class="form-control" name="default_label_format">
|
||||
<option value="PDF"<?= ((string) ($s['default_label_format'] ?? 'PDF')) === 'PDF' ? ' selected' : '' ?>>PDF</option>
|
||||
<option value="ZPL"<?= ((string) ($s['default_label_format'] ?? 'PDF')) === 'ZPL' ? ' selected' : '' ?>>ZPL</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="form-actions mt-16">
|
||||
<button type="submit" class="btn btn--primary"><?= $e($t('settings.company.actions.save')) ?></button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
133
resources/views/settings/inpost.php
Normal file
133
resources/views/settings/inpost.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
$s = is_array($settings ?? null) ? $settings : [];
|
||||
$hasToken = (bool) ($s['has_api_token'] ?? false);
|
||||
$env = (string) ($s['environment'] ?? 'sandbox');
|
||||
$dispatchMethod = (string) ($s['default_dispatch_method'] ?? 'pop');
|
||||
$lockerSize = (string) ($s['default_locker_size'] ?? 'small');
|
||||
$labelFormat = (string) ($s['label_format'] ?? 'Pdf');
|
||||
?>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="section-title"><?= $e($t('settings.inpost.title')) ?></h2>
|
||||
<p class="muted mt-12"><?= $e($t('settings.inpost.description')) ?></p>
|
||||
|
||||
<?php if (!empty($errorMessage)): ?>
|
||||
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($successMessage)): ?>
|
||||
<div class="alert alert--success mt-12" role="status"><?= $e((string) $successMessage) ?></div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="card mt-16">
|
||||
<h3 class="section-title"><?= $e($t('settings.inpost.config.title')) ?></h3>
|
||||
<form action="/settings/integrations/inpost/save" method="post" novalidate>
|
||||
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||
|
||||
<label class="form-field mt-16">
|
||||
<span class="field-label"><?= $e($t('settings.inpost.fields.api_token')) ?></span>
|
||||
<input class="form-control" type="password" name="api_token" autocomplete="new-password">
|
||||
<span class="muted"><?= $e($hasToken ? $t('settings.inpost.api_token.saved') : $t('settings.inpost.api_token.missing')) ?></span>
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.inpost.fields.organization_id')) ?></span>
|
||||
<input class="form-control" type="text" name="organization_id" value="<?= $e((string) ($s['organization_id'] ?? '')) ?>">
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.inpost.fields.environment')) ?></span>
|
||||
<select class="form-control" name="environment">
|
||||
<option value="sandbox"<?= $env === 'sandbox' ? ' selected' : '' ?>>Sandbox</option>
|
||||
<option value="production"<?= $env === 'production' ? ' selected' : '' ?>>Production</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<h4 class="section-title mt-16"><?= $e($t('settings.inpost.sections.dispatch')) ?></h4>
|
||||
|
||||
<label class="form-field mt-12">
|
||||
<span class="field-label"><?= $e($t('settings.inpost.fields.default_dispatch_method')) ?></span>
|
||||
<select class="form-control" name="default_dispatch_method">
|
||||
<option value="pop"<?= $dispatchMethod === 'pop' ? ' selected' : '' ?>><?= $e($t('settings.inpost.dispatch_methods.pop')) ?></option>
|
||||
<option value="parcel_locker"<?= $dispatchMethod === 'parcel_locker' ? ' selected' : '' ?>><?= $e($t('settings.inpost.dispatch_methods.parcel_locker')) ?></option>
|
||||
<option value="courier"<?= $dispatchMethod === 'courier' ? ' selected' : '' ?>><?= $e($t('settings.inpost.dispatch_methods.courier')) ?></option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.inpost.fields.default_dispatch_point')) ?></span>
|
||||
<input class="form-control" type="text" name="default_dispatch_point" value="<?= $e((string) ($s['default_dispatch_point'] ?? '')) ?>" placeholder="np. RZE14N">
|
||||
</label>
|
||||
|
||||
<h4 class="section-title mt-16"><?= $e($t('settings.inpost.sections.locker')) ?></h4>
|
||||
|
||||
<label class="form-field mt-12">
|
||||
<span class="field-label"><?= $e($t('settings.inpost.fields.default_locker_size')) ?></span>
|
||||
<select class="form-control" name="default_locker_size">
|
||||
<option value="small"<?= $lockerSize === 'small' ? ' selected' : '' ?>>A (8 x 38 x 64 cm)</option>
|
||||
<option value="medium"<?= $lockerSize === 'medium' ? ' selected' : '' ?>>B (19 x 38 x 64 cm)</option>
|
||||
<option value="large"<?= $lockerSize === 'large' ? ' selected' : '' ?>>C (41 x 38 x 64 cm)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.inpost.fields.default_insurance')) ?></span>
|
||||
<input class="form-control" type="number" name="default_insurance" step="0.01" min="0" value="<?= $e($s['default_insurance'] !== null ? (string) $s['default_insurance'] : '') ?>" placeholder="<?= $e($t('settings.inpost.fields.insurance_placeholder')) ?>">
|
||||
</label>
|
||||
|
||||
<h4 class="section-title mt-16"><?= $e($t('settings.inpost.sections.courier')) ?></h4>
|
||||
|
||||
<div class="form-grid-3 mt-12">
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.inpost.fields.courier_length')) ?></span>
|
||||
<input class="form-control" type="number" name="default_courier_length" min="1" value="<?= $e((string) ($s['default_courier_length'] ?? 20)) ?>">
|
||||
<span class="muted">cm</span>
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.inpost.fields.courier_width')) ?></span>
|
||||
<input class="form-control" type="number" name="default_courier_width" min="1" value="<?= $e((string) ($s['default_courier_width'] ?? 15)) ?>">
|
||||
<span class="muted">cm</span>
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.inpost.fields.courier_height')) ?></span>
|
||||
<input class="form-control" type="number" name="default_courier_height" min="1" value="<?= $e((string) ($s['default_courier_height'] ?? 8)) ?>">
|
||||
<span class="muted">cm</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h4 class="section-title mt-16"><?= $e($t('settings.inpost.sections.other')) ?></h4>
|
||||
|
||||
<label class="form-field mt-12">
|
||||
<span class="field-label"><?= $e($t('settings.inpost.fields.label_format')) ?></span>
|
||||
<select class="form-control" name="label_format">
|
||||
<option value="Pdf"<?= $labelFormat === 'Pdf' ? ' selected' : '' ?>>PDF A6</option>
|
||||
<option value="Zpl"<?= $labelFormat === 'Zpl' ? ' selected' : '' ?>>ZPL</option>
|
||||
<option value="Epl"<?= $labelFormat === 'Epl' ? ' selected' : '' ?>>EPL</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="mt-12">
|
||||
<label class="form-field form-field--inline">
|
||||
<input type="checkbox" name="weekend_delivery" value="1"<?= !empty($s['weekend_delivery']) ? ' checked' : '' ?>>
|
||||
<span class="field-label"><?= $e($t('settings.inpost.fields.weekend_delivery')) ?></span>
|
||||
</label>
|
||||
|
||||
<label class="form-field form-field--inline">
|
||||
<input type="checkbox" name="auto_insurance_value" value="1"<?= !empty($s['auto_insurance_value']) ? ' checked' : '' ?>>
|
||||
<span class="field-label"><?= $e($t('settings.inpost.fields.auto_insurance_value')) ?></span>
|
||||
</label>
|
||||
|
||||
<label class="form-field form-field--inline">
|
||||
<input type="checkbox" name="multi_parcel" value="1"<?= !empty($s['multi_parcel']) ? ' checked' : '' ?>>
|
||||
<span class="field-label"><?= $e($t('settings.inpost.fields.multi_parcel')) ?></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions mt-16">
|
||||
<button type="submit" class="btn btn--primary"><?= $e($t('settings.inpost.actions.save')) ?></button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
627
resources/views/shipments/prepare.php
Normal file
627
resources/views/shipments/prepare.php
Normal file
@@ -0,0 +1,627 @@
|
||||
<?php
|
||||
$orderRow = is_array($order ?? null) ? $order : [];
|
||||
$itemsList = is_array($items ?? null) ? $items : [];
|
||||
$receiver = is_array($receiverAddr ?? null) ? $receiverAddr : [];
|
||||
$prefs = is_array($preferences ?? null) ? $preferences : [];
|
||||
$comp = is_array($company ?? null) ? $company : [];
|
||||
$services = is_array($deliveryServices ?? null) ? $deliveryServices : [];
|
||||
$packages = is_array($existingPackages ?? null) ? $existingPackages : [];
|
||||
$servicesError = (string) ($deliveryServicesError ?? '');
|
||||
$flashSuccessMsg = (string) ($flashSuccess ?? '');
|
||||
$flashErrorMsg = (string) ($flashError ?? '');
|
||||
|
||||
$mapping = is_array($deliveryMapping ?? null) ? $deliveryMapping : [];
|
||||
$mappedMethodId = trim((string) ($mapping['allegro_delivery_method_id'] ?? ''));
|
||||
$mappedCredentialsId = trim((string) ($mapping['allegro_credentials_id'] ?? ''));
|
||||
$mappedCarrierId = trim((string) ($mapping['allegro_carrier_id'] ?? ''));
|
||||
$mappedCarrier = trim((string) ($mapping['carrier'] ?? ''));
|
||||
$mappedServiceName = trim((string) ($mapping['allegro_service_name'] ?? ''));
|
||||
$deliveryMethodId = $mappedCarrier === 'allegro' && $mappedMethodId !== ''
|
||||
? $mappedMethodId
|
||||
: ($mappedCarrier !== 'inpost' ? trim((string) ($prefs['delivery_method_id'] ?? ($orderRow['external_carrier_account_id'] ?? ''))) : '');
|
||||
$deliveryMethodName = trim((string) ($orderRow['external_carrier_id'] ?? ''));
|
||||
$inpostSvcList = is_array($inpostServices ?? null) ? $inpostServices : [];
|
||||
$preselectedCarrier = $mappedCarrier !== '' ? $mappedCarrier : ($mappedMethodId !== '' ? 'allegro' : '');
|
||||
$pointId = trim((string) ($receiver['parcel_external_id'] ?? ''));
|
||||
$pointName = trim((string) ($receiver['parcel_name'] ?? ''));
|
||||
$totalWithTax = (float) ($orderRow['total_with_tax'] ?? 0);
|
||||
$currency = strtoupper(trim((string) ($orderRow['currency'] ?? 'PLN')));
|
||||
?>
|
||||
|
||||
<section class="card">
|
||||
<div class="order-details-head">
|
||||
<div>
|
||||
<a href="/orders/<?= $e((string) ($orderId ?? 0)) ?>" class="order-back-link">← Powrot do zamowienia</a>
|
||||
<h2 class="section-title mt-12">Przygotuj przesylke #<?= $e((string) ($orderId ?? 0)) ?></h2>
|
||||
<div class="order-details-sub mt-4">
|
||||
<span><?= $e(ucfirst((string) ($orderRow['source'] ?? ''))) ?> <?= $e((string) ($orderRow['external_order_id'] ?? '')) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($flashSuccessMsg !== ''): ?>
|
||||
<div class="flash flash--success mt-12"><?= $e($flashSuccessMsg) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($flashErrorMsg !== ''): ?>
|
||||
<div class="flash flash--error mt-12"><?= $e($flashErrorMsg) ?></div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<?php if ($packages !== []): ?>
|
||||
<section class="card mt-16">
|
||||
<h3 class="section-title">Utworzone przesylki</h3>
|
||||
<div class="table-wrap mt-12">
|
||||
<table class="table table--details">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Status</th>
|
||||
<th>Nr sledzenia</th>
|
||||
<th>Przewoznik</th>
|
||||
<th>Etykieta</th>
|
||||
<th>Utworzono</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($packages as $pkg): ?>
|
||||
<?php
|
||||
$pkgId = (int) ($pkg['id'] ?? 0);
|
||||
$pkgStatus = (string) ($pkg['status'] ?? 'draft');
|
||||
$pkgTracking = trim((string) ($pkg['tracking_number'] ?? ''));
|
||||
$pkgCarrier = trim((string) ($pkg['carrier_id'] ?? ''));
|
||||
$pkgLabelPath = trim((string) ($pkg['label_path'] ?? ''));
|
||||
$pkgShipmentId = trim((string) ($pkg['shipment_id'] ?? ''));
|
||||
$pkgError = trim((string) ($pkg['error_message'] ?? ''));
|
||||
?>
|
||||
<tr>
|
||||
<td><?= $e((string) $pkgId) ?></td>
|
||||
<td>
|
||||
<span class="order-tag <?= $pkgStatus === 'label_ready' || $pkgStatus === 'created' ? 'is-success' : ($pkgStatus === 'error' ? 'is-danger' : 'is-warn') ?>">
|
||||
<?= $e($pkgStatus) ?>
|
||||
</span>
|
||||
<?php if ($pkgError !== ''): ?>
|
||||
<div class="muted mt-4" style="font-size:0.75rem"><?= $e($pkgError) ?></div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= $e($pkgTracking !== '' ? $pkgTracking : '-') ?></td>
|
||||
<td><?= $e($pkgCarrier !== '' ? $pkgCarrier : '-') ?></td>
|
||||
<td>
|
||||
<?php if ($pkgLabelPath !== ''): ?>
|
||||
<form method="post" action="/orders/<?= $e((string) ($orderId ?? 0)) ?>/shipment/<?= $e((string) $pkgId) ?>/label" style="display:inline">
|
||||
<input type="hidden" name="_csrf_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||
<button type="submit" class="btn btn--sm btn--secondary">Pobierz</button>
|
||||
</form>
|
||||
<?php elseif ($pkgShipmentId !== '' && $pkgStatus === 'created'): ?>
|
||||
<form method="post" action="/orders/<?= $e((string) ($orderId ?? 0)) ?>/shipment/<?= $e((string) $pkgId) ?>/label" style="display:inline">
|
||||
<input type="hidden" name="_csrf_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||
<button type="submit" class="btn btn--sm btn--primary">Generuj etykiete</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
-
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-nowrap"><?= $e((string) ($pkg['created_at'] ?? '')) ?></td>
|
||||
<td>
|
||||
<?php if ($pkgStatus === 'pending'): ?>
|
||||
<button type="button" class="btn btn--sm btn--secondary" data-check-status="<?= $e((string) $pkgId) ?>" data-order-id="<?= $e((string) ($orderId ?? 0)) ?>">Sprawdz status</button>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="/orders/<?= $e((string) ($orderId ?? 0)) ?>/shipment/create" novalidate>
|
||||
<input type="hidden" name="_csrf_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||
|
||||
<div class="shipment-grid mt-16">
|
||||
<section class="card">
|
||||
<h3 class="section-title">Przesylka</h3>
|
||||
|
||||
<?php if ($servicesError !== ''): ?>
|
||||
<div class="flash flash--error mt-12"><?= $e($servicesError) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="form-field mt-12">
|
||||
<span class="field-label">Przewoznik</span>
|
||||
<select class="form-control" id="shipment-carrier-select">
|
||||
<option value="">-- Wybierz --</option>
|
||||
<option value="allegro"<?= $preselectedCarrier === 'allegro' ? ' selected' : '' ?>>Allegro</option>
|
||||
<option value="inpost"<?= $preselectedCarrier === 'inpost' ? ' selected' : '' ?>>InPost</option>
|
||||
</select>
|
||||
<?php if ($deliveryMethodName !== ''): ?>
|
||||
<div class="muted mt-4" style="font-size:12px">Metoda z zamowienia: <strong><?= $e($deliveryMethodName) ?></strong><?php if ($mappedServiceName !== ''): ?> → <?= $e($mappedCarrier === 'inpost' ? 'InPost' : 'Allegro') ?>: <?= $e($mappedServiceName) ?><?php endif; ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="form-field mt-12">
|
||||
<span class="field-label">Usluga dostawy</span>
|
||||
<input type="hidden" name="delivery_method_id" id="shipment-delivery-service" value="" required>
|
||||
|
||||
<div id="shipment-allegro-panel" style="<?= $preselectedCarrier !== 'allegro' ? 'display:none' : '' ?>">
|
||||
<?php if ($servicesError !== ''): ?>
|
||||
<div class="flash flash--error mt-4"><?= $e($servicesError) ?></div>
|
||||
<?php endif; ?>
|
||||
<div class="searchable-select" id="shipment-service-wrapper" data-match-id="<?= $e($deliveryMethodId) ?>">
|
||||
<input type="text" class="form-control" id="shipment-service-search" placeholder="Szukaj uslugi dostawy Allegro..." autocomplete="off">
|
||||
<div class="searchable-select__dropdown" id="shipment-service-dropdown">
|
||||
<?php foreach ($services as $svc): ?>
|
||||
<?php
|
||||
$svcId = is_array($svc['id'] ?? null) ? $svc['id'] : [];
|
||||
$svcMethodId = trim((string) ($svcId['deliveryMethodId'] ?? ''));
|
||||
$svcCredentialsId = trim((string) ($svcId['credentialsId'] ?? ''));
|
||||
$svcName = trim((string) ($svc['name'] ?? ''));
|
||||
$svcCarrierId = trim((string) ($svc['carrierId'] ?? ''));
|
||||
$svcOwner = trim((string) ($svc['owner'] ?? ''));
|
||||
?>
|
||||
<div class="searchable-select__option"
|
||||
data-value="<?= $e($svcMethodId) ?>"
|
||||
data-credentials-id="<?= $e($svcCredentialsId) ?>"
|
||||
data-carrier-id="<?= $e($svcCarrierId) ?>"
|
||||
data-owner="<?= $e($svcOwner) ?>"
|
||||
data-label="<?= $e($svcName) ?> (<?= $e($svcOwner) ?>)"
|
||||
><?= $e($svcName) ?> <span class="muted">(<?= $e($svcOwner) ?>)</span></div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="shipment-inpost-panel" style="<?= $preselectedCarrier !== 'inpost' ? 'display:none' : '' ?>">
|
||||
<select class="form-control" id="shipment-inpost-select">
|
||||
<option value="">-- Wybierz usluge InPost --</option>
|
||||
<?php foreach ($inpostSvcList as $inSvc): ?>
|
||||
<option value="<?= $e((string) ($inSvc['id'] ?? '')) ?>"<?= $mappedCarrier === 'inpost' && $mappedMethodId === (string) ($inSvc['id'] ?? '') ? ' selected' : '' ?>>
|
||||
<?= $e((string) ($inSvc['name'] ?? '')) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="shipment-empty-panel" class="muted" style="<?= $preselectedCarrier !== '' ? 'display:none' : '' ?>">Wybierz przewoznika</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="credentials_id" id="shipment-credentials-id" value="">
|
||||
<input type="hidden" name="carrier_id" id="shipment-carrier-id" value="">
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label">Typ paczki</span>
|
||||
<select class="form-control" name="package_type">
|
||||
<option value="PACKAGE" selected>Paczka</option>
|
||||
<option value="DOX">Dokument</option>
|
||||
<option value="PALLET">Paleta</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="form-grid-4">
|
||||
<label class="form-field">
|
||||
<span class="field-label">Dlugosc (cm)</span>
|
||||
<input class="form-control" type="number" name="length_cm" step="0.1" min="0.1" value="<?= $e((string) ($comp['default_package_length_cm'] ?? '25')) ?>">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label">Szerokosc (cm)</span>
|
||||
<input class="form-control" type="number" name="width_cm" step="0.1" min="0.1" value="<?= $e((string) ($comp['default_package_width_cm'] ?? '20')) ?>">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label">Wysokosc (cm)</span>
|
||||
<input class="form-control" type="number" name="height_cm" step="0.1" min="0.1" value="<?= $e((string) ($comp['default_package_height_cm'] ?? '8')) ?>">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label">Waga (kg)</span>
|
||||
<input class="form-control" type="number" name="weight_kg" step="0.001" min="0.001" value="<?= $e((string) ($comp['default_package_weight_kg'] ?? '1')) ?>">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-grid-2">
|
||||
<label class="form-field">
|
||||
<span class="field-label">Ubezpieczenie (<?= $e($currency) ?>)</span>
|
||||
<input class="form-control" type="number" name="insurance_amount" step="0.01" min="0" value="<?= $e(number_format($totalWithTax, 2, '.', '')) ?>">
|
||||
<input type="hidden" name="insurance_currency" value="<?= $e($currency) ?>">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label">Format etykiety</span>
|
||||
<select class="form-control" name="label_format">
|
||||
<option value="PDF"<?= ((string) ($comp['default_label_format'] ?? 'PDF')) === 'PDF' ? ' selected' : '' ?>>PDF</option>
|
||||
<option value="ZPL"<?= ((string) ($comp['default_label_format'] ?? 'PDF')) === 'ZPL' ? ' selected' : '' ?>>ZPL</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label">Punkt nadania (opcjonalnie)</span>
|
||||
<input class="form-control" type="text" name="sender_point_id" maxlength="64" placeholder="np. KRA010">
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3 class="section-title">Adres odbiorcy</h3>
|
||||
|
||||
<label class="form-field mt-12">
|
||||
<span class="field-label">Imie i nazwisko</span>
|
||||
<input class="form-control" type="text" name="receiver_name" maxlength="200" value="<?= $e((string) ($receiver['name'] ?? '')) ?>" required>
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label">Firma (opcjonalnie)</span>
|
||||
<input class="form-control" type="text" name="receiver_company" maxlength="200" value="<?= $e((string) ($receiver['company_name'] ?? '')) ?>">
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label">Ulica</span>
|
||||
<input class="form-control" type="text" name="receiver_street" maxlength="200" value="<?= $e((string) ($receiver['street_name'] ?? '')) ?>" required>
|
||||
</label>
|
||||
|
||||
<div class="form-grid-3">
|
||||
<label class="form-field">
|
||||
<span class="field-label">Kod pocztowy</span>
|
||||
<input class="form-control" type="text" name="receiver_postal_code" maxlength="16" value="<?= $e((string) ($receiver['zip_code'] ?? '')) ?>" required>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label">Miasto</span>
|
||||
<input class="form-control" type="text" name="receiver_city" maxlength="128" value="<?= $e((string) ($receiver['city'] ?? '')) ?>" required>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label">Kraj</span>
|
||||
<input class="form-control" type="text" name="receiver_country_code" maxlength="2" value="<?= $e((string) ($receiver['country'] ?? 'PL')) ?>" required>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-grid-2">
|
||||
<label class="form-field">
|
||||
<span class="field-label">Telefon</span>
|
||||
<input class="form-control" type="tel" name="receiver_phone" maxlength="64" value="<?= $e((string) ($receiver['phone'] ?? '')) ?>" required>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label">E-mail</span>
|
||||
<input class="form-control" type="email" name="receiver_email" maxlength="128" value="<?= $e((string) ($receiver['email'] ?? '')) ?>" required>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<?php if ($pointId !== ''): ?>
|
||||
<label class="form-field">
|
||||
<span class="field-label">Punkt odbioru<?= $pointName !== '' ? ' (' . $e($pointName) . ')' : '' ?></span>
|
||||
<input class="form-control" type="text" name="receiver_point_id" maxlength="64" value="<?= $e($pointId) ?>">
|
||||
</label>
|
||||
<?php else: ?>
|
||||
<label class="form-field">
|
||||
<span class="field-label">Punkt odbioru (opcjonalnie)</span>
|
||||
<input class="form-control" type="text" name="receiver_point_id" maxlength="64" value="">
|
||||
</label>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card mt-16">
|
||||
<h3 class="section-title">Pozycje zamowienia (<?= $e((string) count($itemsList)) ?>)</h3>
|
||||
<div class="table-wrap mt-12">
|
||||
<table class="table table--details">
|
||||
<thead>
|
||||
<tr><th>Lp.</th><th>Nazwa</th><th>Ilosc</th><th>Cena</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($itemsList as $idx => $item): ?>
|
||||
<tr>
|
||||
<td><?= $e((string) ($idx + 1)) ?></td>
|
||||
<td><?= $e((string) ($item['original_name'] ?? '')) ?></td>
|
||||
<td><?= $e((string) ($item['quantity'] ?? 0)) ?></td>
|
||||
<td><?= $e($item['original_price_with_tax'] !== null ? number_format((float) $item['original_price_with_tax'], 2, '.', ' ') : '-') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="form-actions mt-16">
|
||||
<button type="submit" class="btn btn--primary">Utworz przesylke</button>
|
||||
<a href="/orders/<?= $e((string) ($orderId ?? 0)) ?>" class="btn btn--secondary">Anuluj</a>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// ── Generic searchable select enhancer ──
|
||||
function enhanceSelect(selectEl) {
|
||||
if (!selectEl || selectEl.dataset.enhanced) return;
|
||||
selectEl.dataset.enhanced = '1';
|
||||
|
||||
var parent = selectEl.parentNode;
|
||||
var container = document.createElement('div');
|
||||
container.className = 'searchable-select';
|
||||
parent.insertBefore(container, selectEl);
|
||||
container.appendChild(selectEl);
|
||||
selectEl.style.display = 'none';
|
||||
|
||||
var trigger = document.createElement('div');
|
||||
trigger.className = 'searchable-select__trigger form-control';
|
||||
container.appendChild(trigger);
|
||||
|
||||
var dd = document.createElement('div');
|
||||
dd.className = 'searchable-select__dropdown';
|
||||
container.appendChild(dd);
|
||||
|
||||
var search = document.createElement('input');
|
||||
search.type = 'text';
|
||||
search.className = 'searchable-select__search form-control';
|
||||
search.placeholder = 'Szukaj...';
|
||||
search.autocomplete = 'off';
|
||||
dd.appendChild(search);
|
||||
|
||||
var optEls = [];
|
||||
Array.from(selectEl.options).forEach(function (opt) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'searchable-select__option';
|
||||
div.setAttribute('data-value', opt.value);
|
||||
div.textContent = opt.textContent.trim();
|
||||
if (opt.selected) div.classList.add('is-selected');
|
||||
dd.appendChild(div);
|
||||
optEls.push(div);
|
||||
});
|
||||
|
||||
function syncTrigger() {
|
||||
var opt = selectEl.options[selectEl.selectedIndex];
|
||||
var text = opt ? opt.textContent.trim() : '';
|
||||
trigger.textContent = text;
|
||||
trigger.classList.toggle('searchable-select__trigger--placeholder', !selectEl.value);
|
||||
optEls.forEach(function (d) {
|
||||
d.classList.toggle('is-selected', d.getAttribute('data-value') === selectEl.value);
|
||||
});
|
||||
}
|
||||
syncTrigger();
|
||||
|
||||
var isOpen = false;
|
||||
|
||||
function open() {
|
||||
dd.classList.add('is-open');
|
||||
isOpen = true;
|
||||
search.value = '';
|
||||
filterOpts('');
|
||||
setTimeout(function () { search.focus(); }, 0);
|
||||
}
|
||||
|
||||
function close() {
|
||||
dd.classList.remove('is-open');
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function pick(div) {
|
||||
selectEl.value = div.getAttribute('data-value');
|
||||
syncTrigger();
|
||||
close();
|
||||
selectEl.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
function filterOpts(q) {
|
||||
q = q.toLowerCase().trim();
|
||||
optEls.forEach(function (d) {
|
||||
d.style.display = q === '' || d.textContent.toLowerCase().indexOf(q) !== -1 ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
trigger.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
isOpen ? close() : open();
|
||||
});
|
||||
|
||||
search.addEventListener('input', function () {
|
||||
filterOpts(search.value);
|
||||
});
|
||||
|
||||
optEls.forEach(function (d) {
|
||||
d.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault();
|
||||
pick(d);
|
||||
});
|
||||
});
|
||||
|
||||
search.addEventListener('blur', function () {
|
||||
setTimeout(close, 150);
|
||||
});
|
||||
|
||||
search.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') close();
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (isOpen && !container.contains(e.target)) close();
|
||||
});
|
||||
|
||||
selectEl._syncTrigger = syncTrigger;
|
||||
}
|
||||
|
||||
// ── Enhance all native selects on the page ──
|
||||
var carrierSelect = document.getElementById('shipment-carrier-select');
|
||||
var inpostSelect = document.getElementById('shipment-inpost-select');
|
||||
|
||||
document.querySelectorAll('form select.form-control').forEach(function (sel) {
|
||||
enhanceSelect(sel);
|
||||
});
|
||||
|
||||
// ── Carrier / service panel logic ──
|
||||
var allegroPanel = document.getElementById('shipment-allegro-panel');
|
||||
var inpostPanel = document.getElementById('shipment-inpost-panel');
|
||||
var emptyPanel = document.getElementById('shipment-empty-panel');
|
||||
|
||||
var wrapper = document.getElementById('shipment-service-wrapper');
|
||||
var hiddenInput = document.getElementById('shipment-delivery-service');
|
||||
var searchInput = document.getElementById('shipment-service-search');
|
||||
var dropdown = document.getElementById('shipment-service-dropdown');
|
||||
var credentialsInput = document.getElementById('shipment-credentials-id');
|
||||
var carrierInput = document.getElementById('shipment-carrier-id');
|
||||
|
||||
if (!carrierSelect || !hiddenInput) return;
|
||||
|
||||
var allegroOpts = dropdown ? dropdown.querySelectorAll('.searchable-select__option') : [];
|
||||
|
||||
function clearHiddenFields() {
|
||||
hiddenInput.value = '';
|
||||
credentialsInput.value = '';
|
||||
carrierInput.value = '';
|
||||
}
|
||||
|
||||
function showPanel(carrier) {
|
||||
allegroPanel.style.display = carrier === 'allegro' ? '' : 'none';
|
||||
inpostPanel.style.display = carrier === 'inpost' ? '' : 'none';
|
||||
emptyPanel.style.display = carrier === '' ? '' : 'none';
|
||||
}
|
||||
|
||||
// --- Carrier select ---
|
||||
carrierSelect.addEventListener('change', function () {
|
||||
clearHiddenFields();
|
||||
if (searchInput) searchInput.value = '';
|
||||
if (inpostSelect) {
|
||||
inpostSelect.selectedIndex = 0;
|
||||
if (inpostSelect._syncTrigger) inpostSelect._syncTrigger();
|
||||
}
|
||||
allegroOpts.forEach(function (o) { o.classList.remove('is-selected'); });
|
||||
showPanel(carrierSelect.value);
|
||||
});
|
||||
|
||||
// --- InPost select ---
|
||||
if (inpostSelect) {
|
||||
inpostSelect.addEventListener('change', function () {
|
||||
hiddenInput.value = inpostSelect.value;
|
||||
credentialsInput.value = '';
|
||||
carrierInput.value = '';
|
||||
});
|
||||
if (carrierSelect.value === 'inpost' && inpostSelect.value !== '') {
|
||||
hiddenInput.value = inpostSelect.value;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Allegro searchable select ---
|
||||
if (wrapper && searchInput && dropdown) {
|
||||
var isAllegroOpen = false;
|
||||
|
||||
function selectAllegroOption(opt) {
|
||||
hiddenInput.value = opt.getAttribute('data-value') || '';
|
||||
credentialsInput.value = opt.getAttribute('data-credentials-id') || '';
|
||||
carrierInput.value = opt.getAttribute('data-carrier-id') || '';
|
||||
searchInput.value = opt.getAttribute('data-label') || '';
|
||||
closeAllegro();
|
||||
allegroOpts.forEach(function (o) { o.classList.remove('is-selected'); });
|
||||
opt.classList.add('is-selected');
|
||||
}
|
||||
|
||||
function openAllegro() {
|
||||
dropdown.classList.add('is-open');
|
||||
isAllegroOpen = true;
|
||||
}
|
||||
|
||||
function closeAllegro() {
|
||||
dropdown.classList.remove('is-open');
|
||||
isAllegroOpen = false;
|
||||
}
|
||||
|
||||
function filterAllegro(q) {
|
||||
q = q.toLowerCase().trim();
|
||||
allegroOpts.forEach(function (opt) {
|
||||
var label = (opt.getAttribute('data-label') || '').toLowerCase();
|
||||
opt.style.display = q === '' || label.indexOf(q) !== -1 ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
searchInput.addEventListener('focus', function () {
|
||||
filterAllegro(searchInput.value);
|
||||
openAllegro();
|
||||
});
|
||||
|
||||
searchInput.addEventListener('input', function () {
|
||||
filterAllegro(searchInput.value);
|
||||
if (!isAllegroOpen) openAllegro();
|
||||
});
|
||||
|
||||
allegroOpts.forEach(function (opt) {
|
||||
opt.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault();
|
||||
selectAllegroOption(opt);
|
||||
});
|
||||
});
|
||||
|
||||
searchInput.addEventListener('blur', function () {
|
||||
setTimeout(closeAllegro, 150);
|
||||
});
|
||||
|
||||
searchInput.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeAllegro();
|
||||
searchInput.blur();
|
||||
}
|
||||
});
|
||||
|
||||
if (carrierSelect.value === 'allegro') {
|
||||
var matchId = (wrapper.getAttribute('data-match-id') || '').trim();
|
||||
if (matchId !== '') {
|
||||
allegroOpts.forEach(function (opt) {
|
||||
if (opt.getAttribute('data-value') === matchId) {
|
||||
selectAllegroOption(opt);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Check status ---
|
||||
function checkPackageStatus(pkgId, oId, btn, attempt) {
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Sprawdzam...';
|
||||
}
|
||||
fetch('/orders/' + oId + '/shipment/' + pkgId + '/status')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.status === 'created') {
|
||||
window.location.reload();
|
||||
} else if (data.status === 'error') {
|
||||
if (btn) {
|
||||
btn.textContent = 'Blad: ' + (data.error || '');
|
||||
btn.disabled = false;
|
||||
}
|
||||
} else if (attempt < 10) {
|
||||
var delay = Math.min(2000 * Math.pow(1.5, attempt), 15000);
|
||||
setTimeout(function () {
|
||||
checkPackageStatus(pkgId, oId, btn, attempt + 1);
|
||||
}, delay);
|
||||
if (btn) btn.textContent = 'Sprawdzam... (' + (attempt + 1) + ')';
|
||||
} else {
|
||||
if (btn) {
|
||||
btn.textContent = 'W toku... Sprobuj ponownie';
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
if (btn) {
|
||||
btn.textContent = 'Blad sieci';
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Manual check buttons
|
||||
document.querySelectorAll('[data-check-status]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
checkPackageStatus(
|
||||
btn.getAttribute('data-check-status'),
|
||||
btn.getAttribute('data-order-id'),
|
||||
btn,
|
||||
0
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-poll pending packages on page load
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var autoCheckId = params.get('check');
|
||||
if (autoCheckId) {
|
||||
var autoBtn = document.querySelector('[data-check-status="' + autoCheckId + '"]');
|
||||
var autoOrderId = autoBtn ? autoBtn.getAttribute('data-order-id') : params.get('id');
|
||||
if (autoOrderId) {
|
||||
checkPackageStatus(autoCheckId, autoOrderId, autoBtn, 0);
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -9,6 +9,7 @@ use App\Modules\Auth\AuthMiddleware;
|
||||
use App\Modules\Cron\CronRepository;
|
||||
use App\Modules\Orders\OrdersController;
|
||||
use App\Modules\Orders\OrderImportRepository;
|
||||
use App\Modules\Orders\OrdersRepository;
|
||||
use App\Modules\Settings\AllegroApiClient;
|
||||
use App\Modules\Settings\AllegroIntegrationController;
|
||||
use App\Modules\Settings\AllegroIntegrationRepository;
|
||||
@@ -16,8 +17,18 @@ use App\Modules\Settings\AllegroOAuthClient;
|
||||
use App\Modules\Settings\AllegroOrderImportService;
|
||||
use App\Modules\Settings\AllegroStatusDiscoveryService;
|
||||
use App\Modules\Settings\AllegroStatusMappingRepository;
|
||||
use App\Modules\Settings\ApaczkaIntegrationController;
|
||||
use App\Modules\Settings\ApaczkaIntegrationRepository;
|
||||
use App\Modules\Settings\InpostIntegrationController;
|
||||
use App\Modules\Settings\InpostIntegrationRepository;
|
||||
use App\Modules\Settings\AllegroDeliveryMethodMappingRepository;
|
||||
use App\Modules\Settings\CompanySettingsController;
|
||||
use App\Modules\Settings\CompanySettingsRepository;
|
||||
use App\Modules\Settings\CronSettingsController;
|
||||
use App\Modules\Settings\SettingsController;
|
||||
use App\Modules\Shipments\AllegroShipmentService;
|
||||
use App\Modules\Shipments\ShipmentController;
|
||||
use App\Modules\Shipments\ShipmentPackageRepository;
|
||||
use App\Modules\Users\UsersController;
|
||||
|
||||
return static function (Application $app): void {
|
||||
@@ -35,6 +46,7 @@ return static function (Application $app): void {
|
||||
(string) $app->config('app.integrations.secret', '')
|
||||
);
|
||||
$allegroStatusMappingRepository = new AllegroStatusMappingRepository($app->db());
|
||||
$allegroDeliveryMappingRepository = new AllegroDeliveryMethodMappingRepository($app->db());
|
||||
$allegroOAuthClient = new AllegroOAuthClient();
|
||||
$cronRepository = new CronRepository($app->db());
|
||||
$allegroIntegrationController = new AllegroIntegrationController(
|
||||
@@ -51,7 +63,8 @@ return static function (Application $app): void {
|
||||
$allegroOAuthClient,
|
||||
new AllegroApiClient(),
|
||||
new OrderImportRepository($app->db()),
|
||||
$allegroStatusMappingRepository
|
||||
$allegroStatusMappingRepository,
|
||||
new OrdersRepository($app->db())
|
||||
),
|
||||
new AllegroStatusDiscoveryService(
|
||||
$allegroIntegrationRepository,
|
||||
@@ -59,7 +72,29 @@ return static function (Application $app): void {
|
||||
new AllegroApiClient(),
|
||||
$allegroStatusMappingRepository
|
||||
),
|
||||
(string) $app->config('app.url', '')
|
||||
(string) $app->config('app.url', ''),
|
||||
$allegroDeliveryMappingRepository,
|
||||
new AllegroApiClient()
|
||||
);
|
||||
$apaczkaIntegrationRepository = new ApaczkaIntegrationRepository(
|
||||
$app->db(),
|
||||
(string) $app->config('app.integrations.secret', '')
|
||||
);
|
||||
$apaczkaIntegrationController = new ApaczkaIntegrationController(
|
||||
$template,
|
||||
$translator,
|
||||
$auth,
|
||||
$apaczkaIntegrationRepository
|
||||
);
|
||||
$inpostIntegrationRepository = new InpostIntegrationRepository(
|
||||
$app->db(),
|
||||
(string) $app->config('app.integrations.secret', '')
|
||||
);
|
||||
$inpostIntegrationController = new InpostIntegrationController(
|
||||
$template,
|
||||
$translator,
|
||||
$auth,
|
||||
$inpostIntegrationRepository
|
||||
);
|
||||
$cronSettingsController = new CronSettingsController(
|
||||
$template,
|
||||
@@ -69,6 +104,34 @@ return static function (Application $app): void {
|
||||
(bool) $app->config('app.cron.run_on_web_default', false),
|
||||
(int) $app->config('app.cron.web_limit_default', 5)
|
||||
);
|
||||
$companySettingsRepository = new CompanySettingsRepository($app->db());
|
||||
$companySettingsController = new CompanySettingsController(
|
||||
$template,
|
||||
$translator,
|
||||
$auth,
|
||||
$companySettingsRepository
|
||||
);
|
||||
$allegroApiClient = new AllegroApiClient();
|
||||
$shipmentPackageRepository = new ShipmentPackageRepository($app->db());
|
||||
$shipmentService = new AllegroShipmentService(
|
||||
$allegroIntegrationRepository,
|
||||
$allegroOAuthClient,
|
||||
$allegroApiClient,
|
||||
$shipmentPackageRepository,
|
||||
$companySettingsRepository,
|
||||
new OrdersRepository($app->db())
|
||||
);
|
||||
$shipmentController = new ShipmentController(
|
||||
$template,
|
||||
$translator,
|
||||
$auth,
|
||||
$app->orders(),
|
||||
$companySettingsRepository,
|
||||
$shipmentService,
|
||||
$shipmentPackageRepository,
|
||||
$app->basePath('storage'),
|
||||
new AllegroDeliveryMethodMappingRepository($app->db())
|
||||
);
|
||||
$authMiddleware = new AuthMiddleware($auth);
|
||||
|
||||
$router->get('/health', static fn (Request $request): Response => Response::json([
|
||||
@@ -91,6 +154,7 @@ return static function (Application $app): void {
|
||||
$router->get('/orders', static fn (Request $request): Response => Response::redirect('/orders/list'), [$authMiddleware]);
|
||||
$router->get('/orders/list', [$ordersController, 'index'], [$authMiddleware]);
|
||||
$router->get('/orders/{id}', [$ordersController, 'show'], [$authMiddleware]);
|
||||
$router->post('/orders/{id}/status', [$ordersController, 'updateStatus'], [$authMiddleware]);
|
||||
$router->post('/users', [$usersController, 'store'], [$authMiddleware]);
|
||||
$router->get('/settings/users', [$usersController, 'index'], [$authMiddleware]);
|
||||
$router->post('/settings/users', [$usersController, 'store'], [$authMiddleware]);
|
||||
@@ -117,5 +181,16 @@ return static function (Application $app): void {
|
||||
$router->post('/settings/integrations/allegro/statuses/save-bulk', [$allegroIntegrationController, 'saveStatusMappingsBulk'], [$authMiddleware]);
|
||||
$router->post('/settings/integrations/allegro/statuses/delete', [$allegroIntegrationController, 'deleteStatusMapping'], [$authMiddleware]);
|
||||
$router->post('/settings/integrations/allegro/statuses/sync', [$allegroIntegrationController, 'syncStatusesFromAllegro'], [$authMiddleware]);
|
||||
$router->post('/settings/integrations/allegro/delivery/save', [$allegroIntegrationController, 'saveDeliveryMappings'], [$authMiddleware]);
|
||||
$router->get('/settings/integrations/allegro/oauth/callback', [$allegroIntegrationController, 'oauthCallback']);
|
||||
$router->get('/settings/integrations/apaczka', [$apaczkaIntegrationController, 'index'], [$authMiddleware]);
|
||||
$router->post('/settings/integrations/apaczka/save', [$apaczkaIntegrationController, 'save'], [$authMiddleware]);
|
||||
$router->get('/settings/integrations/inpost', [$inpostIntegrationController, 'index'], [$authMiddleware]);
|
||||
$router->post('/settings/integrations/inpost/save', [$inpostIntegrationController, 'save'], [$authMiddleware]);
|
||||
$router->get('/settings/company', [$companySettingsController, 'index'], [$authMiddleware]);
|
||||
$router->post('/settings/company/save', [$companySettingsController, 'save'], [$authMiddleware]);
|
||||
$router->get('/orders/{id}/shipment/prepare', [$shipmentController, 'prepare'], [$authMiddleware]);
|
||||
$router->post('/orders/{id}/shipment/create', [$shipmentController, 'create'], [$authMiddleware]);
|
||||
$router->get('/orders/{id}/shipment/{packageId}/status', [$shipmentController, 'checkStatus'], [$authMiddleware]);
|
||||
$router->post('/orders/{id}/shipment/{packageId}/label', [$shipmentController, 'label'], [$authMiddleware]);
|
||||
};
|
||||
|
||||
@@ -272,7 +272,8 @@ final class Application
|
||||
$oauthClient,
|
||||
$apiClient,
|
||||
new OrderImportRepository($this->db),
|
||||
$statusMappingRepository
|
||||
$statusMappingRepository,
|
||||
new OrdersRepository($this->db)
|
||||
);
|
||||
$ordersSyncService = new AllegroOrdersSyncService(
|
||||
$integrationRepository,
|
||||
|
||||
@@ -107,7 +107,6 @@ final class OrdersController
|
||||
['key' => 'totals', 'label' => $this->translator->get('orders.fields.totals'), 'sortable' => true, 'sort_key' => 'total_with_tax', 'raw' => true],
|
||||
['key' => 'shipping', 'label' => $this->translator->get('orders.fields.shipping'), 'raw' => true],
|
||||
['key' => 'ordered_at', 'label' => $this->translator->get('orders.fields.ordered_at'), 'sortable' => true, 'sort_key' => 'ordered_at'],
|
||||
['key' => 'source_updated_at', 'label' => $this->translator->get('orders.fields.source_updated_at'), 'sortable' => true, 'sort_key' => 'source_updated_at'],
|
||||
],
|
||||
'rows' => $tableRows,
|
||||
'pagination' => [
|
||||
@@ -144,11 +143,20 @@ final class OrdersController
|
||||
$documents = is_array($details['documents'] ?? null) ? $details['documents'] : [];
|
||||
$notes = is_array($details['notes'] ?? null) ? $details['notes'] : [];
|
||||
$history = is_array($details['status_history'] ?? null) ? $details['status_history'] : [];
|
||||
$activityLog = is_array($details['activity_log'] ?? null) ? $details['activity_log'] : [];
|
||||
$statusCode = (string) (($order['effective_status_id'] ?? '') !== '' ? $order['effective_status_id'] : ($order['external_status_id'] ?? ''));
|
||||
$statusCounts = $this->orders->statusCounts();
|
||||
$statusConfig = $this->orders->statusPanelConfig();
|
||||
$statusLabelMap = $this->statusLabelMap($statusConfig);
|
||||
|
||||
$resolvedHistory = $this->resolveHistoryLabels($history, $statusLabelMap);
|
||||
|
||||
$allStatuses = $this->buildAllStatusOptions($statusConfig);
|
||||
|
||||
$flashSuccess = (string) ($_SESSION['order_flash_success'] ?? '');
|
||||
$flashError = (string) ($_SESSION['order_flash_error'] ?? '');
|
||||
unset($_SESSION['order_flash_success'], $_SESSION['order_flash_error']);
|
||||
|
||||
$html = $this->template->render('orders/show', [
|
||||
'title' => $this->translator->get('orders.details.title') . ' #' . $orderId,
|
||||
'activeMenu' => 'orders',
|
||||
@@ -163,14 +171,51 @@ final class OrdersController
|
||||
'shipments' => $shipments,
|
||||
'documents' => $documents,
|
||||
'notes' => $notes,
|
||||
'history' => $history,
|
||||
'history' => $resolvedHistory,
|
||||
'activityLog' => $activityLog,
|
||||
'statusLabel' => $this->statusLabel($statusCode, $statusLabelMap),
|
||||
'statusPanel' => $this->buildStatusPanel($statusConfig, $statusCounts, $statusCode),
|
||||
'allStatuses' => $allStatuses,
|
||||
'currentStatusCode' => $statusCode,
|
||||
'flashSuccess' => $flashSuccess,
|
||||
'flashError' => $flashError,
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
public function updateStatus(Request $request): Response
|
||||
{
|
||||
$orderId = max(0, (int) $request->input('id', 0));
|
||||
if ($orderId <= 0) {
|
||||
return Response::html('Not found', 404);
|
||||
}
|
||||
|
||||
$csrfToken = (string) $request->input('_csrf_token', '');
|
||||
if (!Csrf::validate($csrfToken)) {
|
||||
$_SESSION['order_flash_error'] = $this->translator->get('auth.errors.csrf_expired');
|
||||
return Response::redirect('/orders/' . $orderId);
|
||||
}
|
||||
|
||||
$newStatus = trim((string) $request->input('new_status', ''));
|
||||
if ($newStatus === '') {
|
||||
$_SESSION['order_flash_error'] = $this->translator->get('orders.details.status_change.status_required');
|
||||
return Response::redirect('/orders/' . $orderId);
|
||||
}
|
||||
|
||||
$user = $this->auth->user();
|
||||
$actorName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : null;
|
||||
|
||||
$success = $this->orders->updateOrderStatus($orderId, $newStatus, 'user', $actorName !== '' ? $actorName : null);
|
||||
if ($success) {
|
||||
$_SESSION['order_flash_success'] = $this->translator->get('orders.details.status_change.success');
|
||||
} else {
|
||||
$_SESSION['order_flash_error'] = $this->translator->get('orders.details.status_change.failed');
|
||||
}
|
||||
|
||||
return Response::redirect('/orders/' . $orderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string, mixed>
|
||||
@@ -218,12 +263,12 @@ final class OrdersController
|
||||
. '<div class="orders-money__main">' . htmlspecialchars($totalWithTax . ' ' . $currency, ENT_QUOTES, 'UTF-8') . '</div>'
|
||||
. '<div class="orders-money__meta">oplacono: ' . htmlspecialchars($totalPaid . ' ' . $currency, ENT_QUOTES, 'UTF-8') . '</div>'
|
||||
. '</div>',
|
||||
'shipping' => '<div class="orders-mini">'
|
||||
. '<div>wys.: <strong>' . $shipments . '</strong></div>'
|
||||
. '<div>dok.: <strong>' . $documents . '</strong></div>'
|
||||
. '</div>',
|
||||
'shipping' => $this->shippingHtml(
|
||||
trim((string) ($row['external_carrier_id'] ?? '')),
|
||||
$shipments,
|
||||
$documents
|
||||
),
|
||||
'ordered_at' => (string) ($row['ordered_at'] ?? ''),
|
||||
'source_updated_at' => (string) ($row['source_updated_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -455,9 +500,10 @@ final class OrdersController
|
||||
$mediaUrl = trim((string) ($item['media_url'] ?? ''));
|
||||
|
||||
$thumb = $mediaUrl !== ''
|
||||
? '<button type="button" class="orders-image-trigger js-order-img-open" data-image-url="' . htmlspecialchars($mediaUrl, ENT_QUOTES, 'UTF-8') . '" aria-label="Podglad zdjecia produktu">'
|
||||
? '<span class="orders-image-hover-wrap">'
|
||||
. '<img class="orders-product__thumb" src="' . htmlspecialchars($mediaUrl, ENT_QUOTES, 'UTF-8') . '" alt="">'
|
||||
. '</button>'
|
||||
. '<img class="orders-image-hover-popup" src="' . htmlspecialchars($mediaUrl, ENT_QUOTES, 'UTF-8') . '" alt="">'
|
||||
. '</span>'
|
||||
: '<span class="orders-product__thumb orders-product__thumb--empty"></span>';
|
||||
|
||||
$html .= '<div class="orders-product">'
|
||||
@@ -478,6 +524,18 @@ final class OrdersController
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function shippingHtml(string $deliveryMethod, int $shipments, int $documents): string
|
||||
{
|
||||
$html = '<div class="orders-mini">';
|
||||
if ($deliveryMethod !== '' && !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $deliveryMethod)) {
|
||||
$html .= '<div class="orders-mini__delivery">' . htmlspecialchars($deliveryMethod, ENT_QUOTES, 'UTF-8') . '</div>';
|
||||
}
|
||||
$html .= '<div>wys.: <strong>' . $shipments . '</strong> dok.: <strong>' . $documents . '</strong></div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function formatQuantity(float $value): string
|
||||
{
|
||||
$rounded = round($value, 3);
|
||||
@@ -512,4 +570,47 @@ final class OrdersController
|
||||
'3' => 'zwrocone',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{name:string,color_hex:string,items:array<int, array{code:string,name:string}>}> $config
|
||||
* @return array<int, array{code:string, name:string, group:string}>
|
||||
*/
|
||||
private function buildAllStatusOptions(array $config): array
|
||||
{
|
||||
$options = [];
|
||||
foreach ($config as $group) {
|
||||
$groupName = trim((string) ($group['name'] ?? ''));
|
||||
$items = is_array($group['items'] ?? null) ? $group['items'] : [];
|
||||
foreach ($items as $item) {
|
||||
$code = strtolower(trim((string) ($item['code'] ?? '')));
|
||||
if ($code === '') {
|
||||
continue;
|
||||
}
|
||||
$options[] = [
|
||||
'code' => $code,
|
||||
'name' => (string) ($item['name'] ?? $code),
|
||||
'group' => $groupName,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $history
|
||||
* @param array<string, string> $statusLabelMap
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function resolveHistoryLabels(array $history, array $statusLabelMap): array
|
||||
{
|
||||
return array_map(function (array $entry) use ($statusLabelMap): array {
|
||||
$fromCode = trim((string) ($entry['from_status_id'] ?? ''));
|
||||
$toCode = trim((string) ($entry['to_status_id'] ?? ''));
|
||||
$entry['from_label'] = $fromCode !== '' ? $this->statusLabel($fromCode, $statusLabelMap) : '-';
|
||||
$entry['to_label'] = $toCode !== '' ? $this->statusLabel($toCode, $statusLabelMap) : '-';
|
||||
|
||||
return $entry;
|
||||
}, $history);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ final class OrdersRepository
|
||||
a.name AS buyer_name,
|
||||
a.email AS buyer_email,
|
||||
a.city AS buyer_city,
|
||||
o.external_carrier_id,
|
||||
(SELECT COUNT(*) FROM order_items oi WHERE oi.order_id = o.id) AS items_count,
|
||||
(SELECT COALESCE(SUM(oi.quantity), 0) FROM order_items oi WHERE oi.order_id = o.id) AS items_qty,
|
||||
(SELECT COUNT(*) FROM order_shipments sh WHERE sh.order_id = o.id) AS shipments_count,
|
||||
@@ -170,6 +171,7 @@ final class OrdersRepository
|
||||
'fetched_at' => (string) ($row['fetched_at'] ?? ''),
|
||||
'is_invoice' => (int) ($row['is_invoice'] ?? 0) === 1,
|
||||
'is_canceled_by_buyer' => (int) ($row['is_canceled_by_buyer'] ?? 0) === 1,
|
||||
'external_carrier_id' => (string) ($row['external_carrier_id'] ?? ''),
|
||||
'buyer_name' => (string) ($row['buyer_name'] ?? ''),
|
||||
'buyer_email' => (string) ($row['buyer_email'] ?? ''),
|
||||
'buyer_city' => (string) ($row['buyer_city'] ?? ''),
|
||||
@@ -469,6 +471,8 @@ final class OrdersRepository
|
||||
$history = [];
|
||||
}
|
||||
|
||||
$activityLog = $this->loadActivityLog($orderId);
|
||||
|
||||
return [
|
||||
'order' => $order,
|
||||
'addresses' => $addresses,
|
||||
@@ -478,6 +482,7 @@ final class OrdersRepository
|
||||
'documents' => $documents,
|
||||
'notes' => $notes,
|
||||
'status_history' => $history,
|
||||
'activity_log' => $activityLog,
|
||||
];
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
@@ -636,6 +641,139 @@ final class OrdersRepository
|
||||
return $this->supportsMappedMedia;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function loadActivityLog(int $orderId): array
|
||||
{
|
||||
try {
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT * FROM order_activity_log
|
||||
WHERE order_id = :order_id
|
||||
ORDER BY created_at DESC, id DESC'
|
||||
);
|
||||
$stmt->execute(['order_id' => $orderId]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return is_array($rows) ? $rows : [];
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $details
|
||||
*/
|
||||
public function recordActivity(
|
||||
int $orderId,
|
||||
string $eventType,
|
||||
string $summary,
|
||||
?array $details = null,
|
||||
string $actorType = 'system',
|
||||
?string $actorName = null
|
||||
): void {
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO order_activity_log
|
||||
(order_id, event_type, summary, details_json, actor_type, actor_name, created_at)
|
||||
VALUES
|
||||
(:order_id, :event_type, :summary, :details_json, :actor_type, :actor_name, NOW())'
|
||||
);
|
||||
$stmt->execute([
|
||||
'order_id' => $orderId,
|
||||
'event_type' => $eventType,
|
||||
'summary' => $summary,
|
||||
'details_json' => $details !== null ? json_encode($details, JSON_UNESCAPED_UNICODE) : null,
|
||||
'actor_type' => $actorType,
|
||||
'actor_name' => $actorName,
|
||||
]);
|
||||
}
|
||||
|
||||
public function recordStatusChange(
|
||||
int $orderId,
|
||||
?string $fromStatus,
|
||||
string $toStatus,
|
||||
string $changeSource = 'manual',
|
||||
?string $comment = null,
|
||||
string $actorType = 'system',
|
||||
?string $actorName = null
|
||||
): void {
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO order_status_history
|
||||
(order_id, from_status_id, to_status_id, changed_at, change_source, comment)
|
||||
VALUES
|
||||
(:order_id, :from_status_id, :to_status_id, NOW(), :change_source, :comment)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'order_id' => $orderId,
|
||||
'from_status_id' => $fromStatus,
|
||||
'to_status_id' => $toStatus,
|
||||
'change_source' => $changeSource,
|
||||
'comment' => $comment,
|
||||
]);
|
||||
|
||||
$fromLabel = $fromStatus !== null ? $this->resolveStatusName($fromStatus) : '-';
|
||||
$toLabel = $this->resolveStatusName($toStatus);
|
||||
$summary = 'Zmiana statusu: ' . $fromLabel . ' → ' . $toLabel;
|
||||
|
||||
$this->recordActivity($orderId, 'status_change', $summary, [
|
||||
'from_status' => $fromStatus,
|
||||
'to_status' => $toStatus,
|
||||
'change_source' => $changeSource,
|
||||
'comment' => $comment,
|
||||
], $actorType, $actorName);
|
||||
}
|
||||
|
||||
public function updateOrderStatus(int $orderId, string $newStatusCode, string $actorType = 'user', ?string $actorName = null): bool
|
||||
{
|
||||
try {
|
||||
$stmt = $this->pdo->prepare('SELECT external_status_id FROM orders WHERE id = :id LIMIT 1');
|
||||
$stmt->execute(['id' => $orderId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!is_array($row)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$oldStatus = trim((string) ($row['external_status_id'] ?? ''));
|
||||
|
||||
$update = $this->pdo->prepare('UPDATE orders SET external_status_id = :status, updated_at = NOW() WHERE id = :id');
|
||||
$update->execute(['status' => $newStatusCode, 'id' => $orderId]);
|
||||
|
||||
$this->recordStatusChange(
|
||||
$orderId,
|
||||
$oldStatus !== '' ? $oldStatus : null,
|
||||
$newStatusCode,
|
||||
'manual',
|
||||
null,
|
||||
$actorType,
|
||||
$actorName
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveStatusName(string $code): string
|
||||
{
|
||||
$normalized = strtolower(trim($code));
|
||||
if ($normalized === '') {
|
||||
return $code;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $this->pdo->prepare('SELECT name FROM order_statuses WHERE LOWER(code) = :code LIMIT 1');
|
||||
$stmt->execute(['code' => $normalized]);
|
||||
$name = $stmt->fetchColumn();
|
||||
if (is_string($name) && trim($name) !== '') {
|
||||
return trim($name);
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
private function normalizeColorHex(string $value): string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
|
||||
@@ -38,6 +38,22 @@ final class AllegroApiClient
|
||||
return $this->requestJson($url, $accessToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getCheckoutFormShipments(string $environment, string $accessToken, string $checkoutFormId): array
|
||||
{
|
||||
$safeId = rawurlencode(trim($checkoutFormId));
|
||||
if ($safeId === '') {
|
||||
throw new RuntimeException('Brak ID zamowienia Allegro do pobrania przesylek.');
|
||||
}
|
||||
|
||||
$url = rtrim($this->apiBaseUrl($environment), '/') . '/order/checkout-forms/' . $safeId . '/shipments';
|
||||
$response = $this->requestJson($url, $accessToken);
|
||||
|
||||
return is_array($response['shipments'] ?? null) ? $response['shipments'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -52,6 +68,84 @@ final class AllegroApiClient
|
||||
return $this->requestJson($url, $accessToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getDeliveryServices(string $environment, string $accessToken): array
|
||||
{
|
||||
$url = rtrim($this->apiBaseUrl($environment), '/') . '/shipment-management/delivery-services';
|
||||
return $this->requestJson($url, $accessToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createShipment(string $environment, string $accessToken, array $payload): array
|
||||
{
|
||||
$url = rtrim($this->apiBaseUrl($environment), '/') . '/shipment-management/shipments/create-commands';
|
||||
return $this->postJson($url, $accessToken, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getShipmentCreationStatus(string $environment, string $accessToken, string $commandId): array
|
||||
{
|
||||
$safeId = rawurlencode(trim($commandId));
|
||||
$url = rtrim($this->apiBaseUrl($environment), '/') . '/shipment-management/shipments/create-commands/' . $safeId;
|
||||
return $this->requestJson($url, $accessToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getShipmentDetails(string $environment, string $accessToken, string $shipmentId): array
|
||||
{
|
||||
$safeId = rawurlencode(trim($shipmentId));
|
||||
$url = rtrim($this->apiBaseUrl($environment), '/') . '/shipment-management/shipments/' . $safeId;
|
||||
return $this->requestJson($url, $accessToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $shipmentIds
|
||||
*/
|
||||
public function getShipmentLabel(string $environment, string $accessToken, array $shipmentIds, string $pageSize = 'A6'): string
|
||||
{
|
||||
$url = rtrim($this->apiBaseUrl($environment), '/') . '/shipment-management/label';
|
||||
$payload = [
|
||||
'shipmentIds' => array_values($shipmentIds),
|
||||
'pageSize' => $pageSize,
|
||||
];
|
||||
return $this->postBinary($url, $accessToken, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $lineItems
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function addShipmentToOrder(
|
||||
string $environment,
|
||||
string $accessToken,
|
||||
string $checkoutFormId,
|
||||
string $waybill,
|
||||
string $carrierId,
|
||||
string $carrierName,
|
||||
array $lineItems = []
|
||||
): array {
|
||||
$safeId = rawurlencode(trim($checkoutFormId));
|
||||
$url = rtrim($this->apiBaseUrl($environment), '/') . '/order/checkout-forms/' . $safeId . '/shipments';
|
||||
$body = [
|
||||
'waybill' => $waybill,
|
||||
'carrierId' => $carrierId,
|
||||
'carrierName' => $carrierName,
|
||||
];
|
||||
if ($lineItems !== []) {
|
||||
$body['lineItems'] = $lineItems;
|
||||
}
|
||||
return $this->postJson($url, $accessToken, $body);
|
||||
}
|
||||
|
||||
private function apiBaseUrl(string $environment): string
|
||||
{
|
||||
return trim(strtolower($environment)) === 'production'
|
||||
@@ -59,6 +153,122 @@ final class AllegroApiClient
|
||||
: 'https://api.allegro.pl.allegrosandbox.pl';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $body
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function postJson(string $url, string $accessToken, array $body): array
|
||||
{
|
||||
$jsonBody = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
throw new RuntimeException('Nie udalo sie zainicjowac polaczenia z API Allegro.');
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $jsonBody,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/vnd.allegro.public.v1+json',
|
||||
'Content-Type: application/vnd.allegro.public.v1+json',
|
||||
'Authorization: Bearer ' . $accessToken,
|
||||
],
|
||||
]);
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
$ch = null;
|
||||
|
||||
if ($responseBody === false) {
|
||||
throw new RuntimeException('Blad polaczenia z API Allegro: ' . $curlError);
|
||||
}
|
||||
|
||||
$json = json_decode((string) $responseBody, true);
|
||||
if (!is_array($json)) {
|
||||
throw new RuntimeException('Nieprawidlowy JSON odpowiedzi API Allegro.');
|
||||
}
|
||||
|
||||
if ($httpCode === 401) {
|
||||
throw new RuntimeException('ALLEGRO_HTTP_401');
|
||||
}
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
$message = trim((string) ($json['message'] ?? ''));
|
||||
$errors = is_array($json['errors'] ?? null) ? $json['errors'] : [];
|
||||
if ($message === '' && $errors !== []) {
|
||||
$parts = [];
|
||||
foreach ($errors as $err) {
|
||||
if (is_array($err)) {
|
||||
$parts[] = trim((string) ($err['message'] ?? ($err['userMessage'] ?? '')));
|
||||
}
|
||||
}
|
||||
$message = implode('; ', array_filter($parts));
|
||||
}
|
||||
if ($message === '') {
|
||||
$message = 'Blad API Allegro.';
|
||||
}
|
||||
throw new RuntimeException('API Allegro HTTP ' . $httpCode . ': ' . $message);
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $body
|
||||
*/
|
||||
private function postBinary(string $url, string $accessToken, array $body): string
|
||||
{
|
||||
$jsonBody = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
throw new RuntimeException('Nie udalo sie zainicjowac polaczenia z API Allegro.');
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $jsonBody,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/octet-stream',
|
||||
'Content-Type: application/vnd.allegro.public.v1+json',
|
||||
'Authorization: Bearer ' . $accessToken,
|
||||
],
|
||||
]);
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
$ch = null;
|
||||
|
||||
if ($responseBody === false) {
|
||||
throw new RuntimeException('Blad polaczenia z API Allegro: ' . $curlError);
|
||||
}
|
||||
|
||||
if ($httpCode === 401) {
|
||||
throw new RuntimeException('ALLEGRO_HTTP_401');
|
||||
}
|
||||
|
||||
if ($httpCode === 204) {
|
||||
throw new RuntimeException('Brak etykiety dla podanej przesylki.');
|
||||
}
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
$json = json_decode((string) $responseBody, true);
|
||||
$message = is_array($json) ? trim((string) ($json['message'] ?? 'Blad API Allegro.')) : 'Blad API Allegro.';
|
||||
throw new RuntimeException('API Allegro HTTP ' . $httpCode . ': ' . $message);
|
||||
}
|
||||
|
||||
return (string) $responseBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class AllegroDeliveryMethodMappingRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function listMappings(): array
|
||||
{
|
||||
$stmt = $this->pdo->query('SELECT * FROM allegro_delivery_method_mappings ORDER BY order_delivery_method ASC');
|
||||
$rows = $stmt !== false ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findByOrderMethod(string $orderDeliveryMethod): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM allegro_delivery_method_mappings WHERE order_delivery_method = :method LIMIT 1');
|
||||
$stmt->execute(['method' => $orderDeliveryMethod]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{order_delivery_method: string, allegro_delivery_method_id: string, allegro_credentials_id: string, allegro_carrier_id: string, allegro_service_name: string}> $mappings
|
||||
*/
|
||||
public function saveMappings(array $mappings): void
|
||||
{
|
||||
$this->pdo->exec('DELETE FROM allegro_delivery_method_mappings');
|
||||
|
||||
if ($mappings === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO allegro_delivery_method_mappings
|
||||
(order_delivery_method, carrier, allegro_delivery_method_id, allegro_credentials_id, allegro_carrier_id, allegro_service_name)
|
||||
VALUES
|
||||
(:order_delivery_method, :carrier, :allegro_delivery_method_id, :allegro_credentials_id, :allegro_carrier_id, :allegro_service_name)'
|
||||
);
|
||||
|
||||
foreach ($mappings as $mapping) {
|
||||
$orderMethod = trim((string) ($mapping['order_delivery_method'] ?? ''));
|
||||
$allegroMethodId = trim((string) ($mapping['allegro_delivery_method_id'] ?? ''));
|
||||
$carrier = trim((string) ($mapping['carrier'] ?? 'allegro'));
|
||||
if ($orderMethod === '' || $allegroMethodId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$stmt->execute([
|
||||
'order_delivery_method' => $orderMethod,
|
||||
'carrier' => $carrier !== '' ? $carrier : 'allegro',
|
||||
'allegro_delivery_method_id' => $allegroMethodId,
|
||||
'allegro_credentials_id' => trim((string) ($mapping['allegro_credentials_id'] ?? '')),
|
||||
'allegro_carrier_id' => trim((string) ($mapping['allegro_carrier_id'] ?? '')),
|
||||
'allegro_service_name' => trim((string) ($mapping['allegro_service_name'] ?? '')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getDistinctOrderDeliveryMethods(): array
|
||||
{
|
||||
$stmt = $this->pdo->query(
|
||||
"SELECT DISTINCT external_carrier_id FROM orders
|
||||
WHERE external_carrier_id IS NOT NULL
|
||||
AND external_carrier_id != ''
|
||||
AND source = 'allegro'
|
||||
AND external_carrier_id NOT REGEXP '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
||||
ORDER BY external_carrier_id ASC"
|
||||
);
|
||||
$rows = $stmt !== false ? $stmt->fetchAll(PDO::FETCH_COLUMN) : [];
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ final class AllegroIntegrationController
|
||||
private const OAUTH_SCOPES = [
|
||||
AllegroOAuthClient::ORDERS_READ_SCOPE,
|
||||
AllegroOAuthClient::SALE_OFFERS_READ_SCOPE,
|
||||
AllegroOAuthClient::SHIPMENTS_READ_SCOPE,
|
||||
AllegroOAuthClient::SHIPMENTS_WRITE_SCOPE,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
@@ -48,15 +50,22 @@ final class AllegroIntegrationController
|
||||
private readonly AllegroOAuthClient $oauthClient,
|
||||
private readonly AllegroOrderImportService $orderImportService,
|
||||
private readonly AllegroStatusDiscoveryService $statusDiscoveryService,
|
||||
private readonly string $appUrl
|
||||
private readonly string $appUrl,
|
||||
private readonly ?AllegroDeliveryMethodMappingRepository $deliveryMappings = null,
|
||||
private readonly ?AllegroApiClient $apiClient = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$settings = $this->repository->getSettings();
|
||||
$envParam = trim((string) $request->input('env', ''));
|
||||
$activeEnv = in_array($envParam, ['sandbox', 'production'], true)
|
||||
? $envParam
|
||||
: $this->repository->getActiveEnvironment();
|
||||
$settings = $this->repository->getSettings($activeEnv);
|
||||
|
||||
$tab = trim((string) $request->input('tab', 'integration'));
|
||||
if (!in_array($tab, ['integration', 'statuses', 'settings'], true)) {
|
||||
if (!in_array($tab, ['integration', 'statuses', 'settings', 'delivery'], true)) {
|
||||
$tab = 'integration';
|
||||
}
|
||||
$defaultRedirectUri = $this->defaultRedirectUri();
|
||||
@@ -67,6 +76,7 @@ final class AllegroIntegrationController
|
||||
$importIntervalSeconds = $this->currentImportIntervalSeconds();
|
||||
$statusSyncDirection = $this->currentStatusSyncDirection();
|
||||
$statusSyncIntervalMinutes = $this->currentStatusSyncIntervalMinutes();
|
||||
$deliveryServicesData = $tab === 'delivery' ? $this->loadDeliveryServices($settings) : [[], ''];
|
||||
|
||||
$html = $this->template->render('settings/allegro', [
|
||||
'title' => $this->translator->get('settings.allegro.title'),
|
||||
@@ -85,6 +95,11 @@ final class AllegroIntegrationController
|
||||
'errorMessage' => (string) Flash::get('settings_error', ''),
|
||||
'successMessage' => (string) Flash::get('settings_success', ''),
|
||||
'warningMessage' => (string) Flash::get('settings_warning', ''),
|
||||
'deliveryMappings' => $this->deliveryMappings !== null ? $this->deliveryMappings->listMappings() : [],
|
||||
'orderDeliveryMethods' => $this->deliveryMappings !== null ? $this->deliveryMappings->getDistinctOrderDeliveryMethods() : [],
|
||||
'allegroDeliveryServices' => $deliveryServicesData[0],
|
||||
'allegroDeliveryServicesError' => $deliveryServicesData[1],
|
||||
'inpostDeliveryServices' => $this->inpostServicesList(),
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
@@ -482,6 +497,153 @@ final class AllegroIntegrationController
|
||||
return Response::redirect('/settings/integrations/allegro');
|
||||
}
|
||||
|
||||
public function saveDeliveryMappings(Request $request): Response
|
||||
{
|
||||
$csrfError = $this->validateCsrf((string) $request->input('_token', ''));
|
||||
if ($csrfError !== null) {
|
||||
return $csrfError;
|
||||
}
|
||||
|
||||
if ($this->deliveryMappings === null) {
|
||||
Flash::set('settings_error', 'Delivery mappings not configured.');
|
||||
return Response::redirect('/settings/integrations/allegro?tab=delivery');
|
||||
}
|
||||
|
||||
$orderMethods = (array) $request->input('order_delivery_method', []);
|
||||
$carriers = (array) $request->input('carrier', []);
|
||||
$allegroMethodIds = (array) $request->input('allegro_delivery_method_id', []);
|
||||
$credentialsIds = (array) $request->input('allegro_credentials_id', []);
|
||||
$carrierIds = (array) $request->input('allegro_carrier_id', []);
|
||||
$serviceNames = (array) $request->input('allegro_service_name', []);
|
||||
|
||||
$mappings = [];
|
||||
foreach ($orderMethods as $idx => $orderMethod) {
|
||||
$orderMethod = trim((string) $orderMethod);
|
||||
$carrier = trim((string) ($carriers[$idx] ?? 'allegro'));
|
||||
$allegroMethodId = trim((string) ($allegroMethodIds[$idx] ?? ''));
|
||||
if ($orderMethod === '' || $allegroMethodId === '') {
|
||||
continue;
|
||||
}
|
||||
$mappings[] = [
|
||||
'order_delivery_method' => $orderMethod,
|
||||
'carrier' => $carrier,
|
||||
'allegro_delivery_method_id' => $allegroMethodId,
|
||||
'allegro_credentials_id' => trim((string) ($credentialsIds[$idx] ?? '')),
|
||||
'allegro_carrier_id' => trim((string) ($carrierIds[$idx] ?? '')),
|
||||
'allegro_service_name' => trim((string) ($serviceNames[$idx] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$this->deliveryMappings->saveMappings($mappings);
|
||||
Flash::set('settings_success', $this->translator->get('settings.allegro.delivery.flash.saved'));
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.delivery.flash.save_failed') . ' ' . $exception->getMessage());
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/integrations/allegro?tab=delivery');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id: string, name: string}>
|
||||
*/
|
||||
private function inpostServicesList(): array
|
||||
{
|
||||
return [
|
||||
['id' => 'inpost_locker_standard', 'name' => 'Paczkomat Standard'],
|
||||
['id' => 'inpost_locker_economy', 'name' => 'Paczkomat Economy'],
|
||||
['id' => 'inpost_locker_allegro', 'name' => 'Allegro Paczkomat InPost'],
|
||||
['id' => 'inpost_courier_standard', 'name' => 'Kurier InPost'],
|
||||
['id' => 'inpost_courier_express_1000', 'name' => 'Kurier InPost Express 10:00'],
|
||||
['id' => 'inpost_courier_express_1200', 'name' => 'Kurier InPost Express 12:00'],
|
||||
['id' => 'inpost_courier_express_1700', 'name' => 'Kurier InPost Express 17:00'],
|
||||
['id' => 'inpost_courier_palette', 'name' => 'Kurier InPost Paleta'],
|
||||
['id' => 'inpost_courier_c2c', 'name' => 'Kurier InPost C2C'],
|
||||
['id' => 'inpost_courier_local_standard', 'name' => 'Kurier InPost Lokalny'],
|
||||
['id' => 'inpost_courier_local_express', 'name' => 'Kurier InPost Lokalny Express'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $settings
|
||||
* @return array{0: array<int, array<string, mixed>>, 1: string}
|
||||
*/
|
||||
private function loadDeliveryServices(array $settings): array
|
||||
{
|
||||
if ($this->apiClient === null) {
|
||||
return [[], ''];
|
||||
}
|
||||
|
||||
$isConnected = (bool) ($settings['is_connected'] ?? false);
|
||||
if (!$isConnected) {
|
||||
return [[], $this->translator->get('settings.allegro.delivery.not_connected')];
|
||||
}
|
||||
|
||||
try {
|
||||
$oauth = $this->repository->getTokenCredentials();
|
||||
if ($oauth === null) {
|
||||
return [[], $this->translator->get('settings.allegro.delivery.not_connected')];
|
||||
}
|
||||
$env = (string) ($oauth['environment'] ?? 'sandbox');
|
||||
$accessToken = trim((string) ($oauth['access_token'] ?? ''));
|
||||
if ($accessToken === '') {
|
||||
return [[], $this->translator->get('settings.allegro.delivery.not_connected')];
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->apiClient->getDeliveryServices($env, $accessToken);
|
||||
} catch (RuntimeException $ex) {
|
||||
if (trim($ex->getMessage()) === 'ALLEGRO_HTTP_401') {
|
||||
$refreshed = $this->refreshOAuthToken($oauth);
|
||||
if ($refreshed === null) {
|
||||
return [[], $this->translator->get('settings.allegro.delivery.not_connected')];
|
||||
}
|
||||
$response = $this->apiClient->getDeliveryServices($env, $refreshed);
|
||||
} else {
|
||||
throw $ex;
|
||||
}
|
||||
}
|
||||
|
||||
$services = is_array($response['services'] ?? null) ? $response['services'] : [];
|
||||
return [$services, ''];
|
||||
} catch (Throwable $e) {
|
||||
return [[], $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $oauth
|
||||
*/
|
||||
private function refreshOAuthToken(array $oauth): ?string
|
||||
{
|
||||
try {
|
||||
$token = $this->oauthClient->refreshAccessToken(
|
||||
(string) ($oauth['environment'] ?? 'sandbox'),
|
||||
(string) ($oauth['client_id'] ?? ''),
|
||||
(string) ($oauth['client_secret'] ?? ''),
|
||||
(string) ($oauth['refresh_token'] ?? '')
|
||||
);
|
||||
$expiresIn = max(0, (int) ($token['expires_in'] ?? 0));
|
||||
$expiresAt = $expiresIn > 0
|
||||
? (new DateTimeImmutable('now'))->add(new DateInterval('PT' . $expiresIn . 'S'))->format('Y-m-d H:i:s')
|
||||
: null;
|
||||
$refreshToken = trim((string) ($token['refresh_token'] ?? ''));
|
||||
if ($refreshToken === '') {
|
||||
$refreshToken = (string) ($oauth['refresh_token'] ?? '');
|
||||
}
|
||||
$this->repository->saveTokens(
|
||||
(string) ($token['access_token'] ?? ''),
|
||||
$refreshToken,
|
||||
(string) ($token['token_type'] ?? ''),
|
||||
(string) ($token['scope'] ?? ''),
|
||||
$expiresAt
|
||||
);
|
||||
return trim((string) ($token['access_token'] ?? '')) ?: null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function defaultRedirectUri(): string
|
||||
{
|
||||
$base = trim($this->appUrl);
|
||||
|
||||
@@ -9,6 +9,8 @@ use Throwable;
|
||||
|
||||
final class AllegroIntegrationRepository
|
||||
{
|
||||
private const DEFAULT_ENVIRONMENT = 'sandbox';
|
||||
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly string $secret
|
||||
@@ -18,15 +20,16 @@ final class AllegroIntegrationRepository
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getSettings(): array
|
||||
public function getSettings(?string $environment = null): array
|
||||
{
|
||||
$row = $this->fetchRow();
|
||||
$env = $this->resolveEnvironment($environment);
|
||||
$row = $this->fetchRowByEnv($env);
|
||||
if ($row === null) {
|
||||
return $this->defaultSettings();
|
||||
return $this->defaultSettings($env);
|
||||
}
|
||||
|
||||
return [
|
||||
'environment' => $this->normalizeEnvironment((string) ($row['environment'] ?? 'sandbox')),
|
||||
'environment' => $this->normalizeEnvironment((string) ($row['environment'] ?? self::DEFAULT_ENVIRONMENT)),
|
||||
'client_id' => trim((string) ($row['client_id'] ?? '')),
|
||||
'has_client_secret' => trim((string) ($row['client_secret_encrypted'] ?? '')) !== '',
|
||||
'redirect_uri' => trim((string) ($row['redirect_uri'] ?? '')),
|
||||
@@ -38,13 +41,44 @@ final class AllegroIntegrationRepository
|
||||
];
|
||||
}
|
||||
|
||||
public function getActiveEnvironment(): string
|
||||
{
|
||||
try {
|
||||
$statement = $this->pdo->prepare(
|
||||
"SELECT setting_value FROM app_settings WHERE setting_key = 'allegro_active_environment' LIMIT 1"
|
||||
);
|
||||
$statement->execute();
|
||||
$row = $statement->fetch(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable) {
|
||||
return self::DEFAULT_ENVIRONMENT;
|
||||
}
|
||||
|
||||
if (!is_array($row)) {
|
||||
return self::DEFAULT_ENVIRONMENT;
|
||||
}
|
||||
|
||||
return $this->normalizeEnvironment(trim((string) ($row['setting_value'] ?? self::DEFAULT_ENVIRONMENT)));
|
||||
}
|
||||
|
||||
public function setActiveEnvironment(string $environment): void
|
||||
{
|
||||
$env = $this->normalizeEnvironment($environment);
|
||||
$statement = $this->pdo->prepare(
|
||||
"INSERT INTO app_settings (setting_key, setting_value, updated_at)
|
||||
VALUES ('allegro_active_environment', :env, NOW())
|
||||
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = NOW()"
|
||||
);
|
||||
$statement->execute(['env' => $env]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function saveSettings(array $payload): void
|
||||
{
|
||||
$this->ensureRow();
|
||||
$current = $this->fetchRow();
|
||||
$env = $this->normalizeEnvironment((string) ($payload['environment'] ?? self::DEFAULT_ENVIRONMENT));
|
||||
$this->ensureRow($env);
|
||||
$current = $this->fetchRowByEnv($env);
|
||||
if ($current === null) {
|
||||
throw new RuntimeException('Brak rekordu konfiguracji Allegro.');
|
||||
}
|
||||
@@ -57,23 +91,24 @@ final class AllegroIntegrationRepository
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'UPDATE allegro_integration_settings
|
||||
SET environment = :environment,
|
||||
client_id = :client_id,
|
||||
SET client_id = :client_id,
|
||||
client_secret_encrypted = :client_secret_encrypted,
|
||||
redirect_uri = :redirect_uri,
|
||||
orders_fetch_enabled = :orders_fetch_enabled,
|
||||
orders_fetch_start_date = :orders_fetch_start_date,
|
||||
updated_at = NOW()
|
||||
WHERE id = 1'
|
||||
WHERE environment = :environment'
|
||||
);
|
||||
$statement->execute([
|
||||
'environment' => $this->normalizeEnvironment((string) ($payload['environment'] ?? 'sandbox')),
|
||||
'environment' => $env,
|
||||
'client_id' => $this->nullableString((string) ($payload['client_id'] ?? '')),
|
||||
'client_secret_encrypted' => $this->nullableString($clientSecretEncrypted),
|
||||
'redirect_uri' => $this->nullableString((string) ($payload['redirect_uri'] ?? '')),
|
||||
'orders_fetch_enabled' => ((bool) ($payload['orders_fetch_enabled'] ?? false)) ? 1 : 0,
|
||||
'orders_fetch_start_date' => $this->nullableString((string) ($payload['orders_fetch_start_date'] ?? '')),
|
||||
]);
|
||||
|
||||
$this->setActiveEnvironment($env);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,7 +116,8 @@ final class AllegroIntegrationRepository
|
||||
*/
|
||||
public function getOAuthCredentials(): ?array
|
||||
{
|
||||
$row = $this->fetchRow();
|
||||
$env = $this->getActiveEnvironment();
|
||||
$row = $this->fetchRowByEnv($env);
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
@@ -94,7 +130,7 @@ final class AllegroIntegrationRepository
|
||||
}
|
||||
|
||||
return [
|
||||
'environment' => $this->normalizeEnvironment((string) ($row['environment'] ?? 'sandbox')),
|
||||
'environment' => $this->normalizeEnvironment((string) ($row['environment'] ?? self::DEFAULT_ENVIRONMENT)),
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
'redirect_uri' => $redirectUri,
|
||||
@@ -108,7 +144,8 @@ final class AllegroIntegrationRepository
|
||||
string $scope,
|
||||
?string $tokenExpiresAt
|
||||
): void {
|
||||
$this->ensureRow();
|
||||
$env = $this->getActiveEnvironment();
|
||||
$this->ensureRow($env);
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'UPDATE allegro_integration_settings
|
||||
@@ -119,9 +156,10 @@ final class AllegroIntegrationRepository
|
||||
token_expires_at = :token_expires_at,
|
||||
connected_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = 1'
|
||||
WHERE environment = :environment'
|
||||
);
|
||||
$statement->execute([
|
||||
'environment' => $env,
|
||||
'access_token_encrypted' => $this->encrypt($accessToken),
|
||||
'refresh_token_encrypted' => $this->encrypt($refreshToken),
|
||||
'token_type' => $this->nullableString($tokenType),
|
||||
@@ -135,7 +173,8 @@ final class AllegroIntegrationRepository
|
||||
*/
|
||||
public function getRefreshTokenCredentials(): ?array
|
||||
{
|
||||
$row = $this->fetchRow();
|
||||
$env = $this->getActiveEnvironment();
|
||||
$row = $this->fetchRowByEnv($env);
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
@@ -148,7 +187,7 @@ final class AllegroIntegrationRepository
|
||||
}
|
||||
|
||||
return [
|
||||
'environment' => $this->normalizeEnvironment((string) ($row['environment'] ?? 'sandbox')),
|
||||
'environment' => $this->normalizeEnvironment((string) ($row['environment'] ?? self::DEFAULT_ENVIRONMENT)),
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
'refresh_token' => $refreshToken,
|
||||
@@ -160,7 +199,8 @@ final class AllegroIntegrationRepository
|
||||
*/
|
||||
public function getTokenCredentials(): ?array
|
||||
{
|
||||
$row = $this->fetchRow();
|
||||
$env = $this->getActiveEnvironment();
|
||||
$row = $this->fetchRowByEnv($env);
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
@@ -174,7 +214,7 @@ final class AllegroIntegrationRepository
|
||||
}
|
||||
|
||||
return [
|
||||
'environment' => $this->normalizeEnvironment((string) ($row['environment'] ?? 'sandbox')),
|
||||
'environment' => $this->normalizeEnvironment((string) ($row['environment'] ?? self::DEFAULT_ENVIRONMENT)),
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
'refresh_token' => $refreshToken,
|
||||
@@ -183,30 +223,33 @@ final class AllegroIntegrationRepository
|
||||
];
|
||||
}
|
||||
|
||||
private function ensureRow(): void
|
||||
private function ensureRow(string $environment): void
|
||||
{
|
||||
$env = $this->normalizeEnvironment($environment);
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO allegro_integration_settings (
|
||||
id, environment, orders_fetch_enabled, created_at, updated_at
|
||||
environment, orders_fetch_enabled, created_at, updated_at
|
||||
) VALUES (
|
||||
1, :environment, 0, NOW(), NOW()
|
||||
:environment, 0, NOW(), NOW()
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
updated_at = VALUES(updated_at)'
|
||||
updated_at = updated_at'
|
||||
);
|
||||
$statement->execute([
|
||||
'environment' => 'sandbox',
|
||||
'environment' => $env,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function fetchRow(): ?array
|
||||
private function fetchRowByEnv(string $environment): ?array
|
||||
{
|
||||
try {
|
||||
$statement = $this->pdo->prepare('SELECT * FROM allegro_integration_settings WHERE id = 1 LIMIT 1');
|
||||
$statement->execute();
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT * FROM allegro_integration_settings WHERE environment = :environment LIMIT 1'
|
||||
);
|
||||
$statement->execute(['environment' => $this->normalizeEnvironment($environment)]);
|
||||
$row = $statement->fetch(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
@@ -215,13 +258,22 @@ final class AllegroIntegrationRepository
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
private function resolveEnvironment(?string $environment): string
|
||||
{
|
||||
if ($environment !== null) {
|
||||
return $this->normalizeEnvironment($environment);
|
||||
}
|
||||
|
||||
return $this->getActiveEnvironment();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function defaultSettings(): array
|
||||
private function defaultSettings(string $environment = self::DEFAULT_ENVIRONMENT): array
|
||||
{
|
||||
return [
|
||||
'environment' => 'sandbox',
|
||||
'environment' => $this->normalizeEnvironment($environment),
|
||||
'client_id' => '',
|
||||
'has_client_secret' => false,
|
||||
'redirect_uri' => '',
|
||||
|
||||
@@ -9,6 +9,8 @@ final class AllegroOAuthClient
|
||||
{
|
||||
public const ORDERS_READ_SCOPE = 'allegro:api:orders:read';
|
||||
public const SALE_OFFERS_READ_SCOPE = 'allegro:api:sale:offers:read';
|
||||
public const SHIPMENTS_READ_SCOPE = 'allegro:api:shipments:read';
|
||||
public const SHIPMENTS_WRITE_SCOPE = 'allegro:api:shipments:write';
|
||||
|
||||
/**
|
||||
* @param array<int, string> $scopes
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use App\Modules\Orders\OrderImportRepository;
|
||||
use App\Modules\Orders\OrdersRepository;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use RuntimeException;
|
||||
@@ -16,7 +17,8 @@ final class AllegroOrderImportService
|
||||
private readonly AllegroOAuthClient $oauthClient,
|
||||
private readonly AllegroApiClient $apiClient,
|
||||
private readonly OrderImportRepository $orders,
|
||||
private readonly AllegroStatusMappingRepository $statusMappings
|
||||
private readonly AllegroStatusMappingRepository $statusMappings,
|
||||
private readonly OrdersRepository $ordersRepository
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -67,9 +69,30 @@ final class AllegroOrderImportService
|
||||
$mapped['status_history']
|
||||
);
|
||||
|
||||
$savedOrderId = (int) ($saveResult['order_id'] ?? 0);
|
||||
$wasCreated = !empty($saveResult['created']);
|
||||
|
||||
if ($savedOrderId > 0) {
|
||||
$summary = $wasCreated
|
||||
? 'Zaimportowano zamowienie z Allegro'
|
||||
: 'Zaktualizowano zamowienie z Allegro (re-import)';
|
||||
$this->ordersRepository->recordActivity(
|
||||
$savedOrderId,
|
||||
'import',
|
||||
$summary,
|
||||
[
|
||||
'source' => 'allegro',
|
||||
'source_order_id' => trim($checkoutFormId),
|
||||
'created' => $wasCreated,
|
||||
],
|
||||
'import',
|
||||
'Allegro'
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'order_id' => (int) ($saveResult['order_id'] ?? 0),
|
||||
'created' => !empty($saveResult['created']),
|
||||
'order_id' => $savedOrderId,
|
||||
'created' => $wasCreated,
|
||||
'source_order_id' => (string) ($mapped['order']['source_order_id'] ?? ''),
|
||||
'image_diagnostics' => (array) ($mapped['image_diagnostics'] ?? []),
|
||||
];
|
||||
@@ -271,7 +294,12 @@ final class AllegroOrderImportService
|
||||
$itemsResult = $this->buildItems($lineItems, $environment, $accessToken);
|
||||
$items = (array) ($itemsResult['items'] ?? []);
|
||||
$payments = $this->buildPayments($payment, $currency);
|
||||
$shipments = $this->buildShipments($payload, $delivery);
|
||||
$apiShipments = [];
|
||||
try {
|
||||
$apiShipments = $this->apiClient->getCheckoutFormShipments($environment, $accessToken, $checkoutFormId);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
$shipments = $this->buildShipments($apiShipments, $delivery);
|
||||
$notes = $this->buildNotes($payload);
|
||||
$statusHistory = [[
|
||||
'from_status_id' => null,
|
||||
@@ -683,33 +711,30 @@ final class AllegroOrderImportService
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<int, array<string, mixed>> $apiShipments
|
||||
* @param array<string, mixed> $delivery
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function buildShipments(array $payload, array $delivery): array
|
||||
private function buildShipments(array $apiShipments, array $delivery): array
|
||||
{
|
||||
$shipments = is_array($payload['fulfillment']['shipments'] ?? null)
|
||||
? $payload['fulfillment']['shipments']
|
||||
: [];
|
||||
|
||||
$result = [];
|
||||
foreach ($shipments as $shipmentRaw) {
|
||||
foreach ($apiShipments as $shipmentRaw) {
|
||||
if (!is_array($shipmentRaw)) {
|
||||
continue;
|
||||
}
|
||||
$trackingNumber = trim((string) ($shipmentRaw['waybill'] ?? $shipmentRaw['trackingNumber'] ?? ''));
|
||||
$trackingNumber = trim((string) ($shipmentRaw['waybill'] ?? ''));
|
||||
if ($trackingNumber === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$carrierId = trim((string) ($shipmentRaw['carrierId'] ?? $delivery['method']['id'] ?? 'allegro'));
|
||||
$carrierId = trim((string) ($shipmentRaw['carrierId'] ?? $delivery['method']['id'] ?? ''));
|
||||
$carrierName = trim((string) ($shipmentRaw['carrierName'] ?? ''));
|
||||
$result[] = [
|
||||
'source_shipment_id' => $this->nullableString((string) ($shipmentRaw['id'] ?? '')),
|
||||
'external_shipment_id' => $this->nullableString((string) ($shipmentRaw['id'] ?? '')),
|
||||
'tracking_number' => $trackingNumber,
|
||||
'carrier_provider_id' => $carrierId !== '' ? $carrierId : 'allegro',
|
||||
'posted_at' => $this->normalizeDateTime((string) ($shipmentRaw['createdAt'] ?? $payload['updatedAt'] ?? '')),
|
||||
'carrier_provider_id' => $carrierId !== '' ? $carrierId : ($carrierName !== '' ? $carrierName : 'allegro'),
|
||||
'posted_at' => $this->normalizeDateTime((string) ($shipmentRaw['createdAt'] ?? '')),
|
||||
'media_uuid' => null,
|
||||
'payload_json' => $shipmentRaw,
|
||||
];
|
||||
|
||||
70
src/Modules/Settings/ApaczkaIntegrationController.php
Normal file
70
src/Modules/Settings/ApaczkaIntegrationController.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use App\Core\Http\Request;
|
||||
use App\Core\Http\Response;
|
||||
use App\Core\I18n\Translator;
|
||||
use App\Core\Security\Csrf;
|
||||
use App\Core\Support\Flash;
|
||||
use App\Core\View\Template;
|
||||
use App\Modules\Auth\AuthService;
|
||||
use Throwable;
|
||||
|
||||
final class ApaczkaIntegrationController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Template $template,
|
||||
private readonly Translator $translator,
|
||||
private readonly AuthService $auth,
|
||||
private readonly ApaczkaIntegrationRepository $repository
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$settings = $this->repository->getSettings();
|
||||
|
||||
$html = $this->template->render('settings/apaczka', [
|
||||
'title' => $this->translator->get('settings.apaczka.title'),
|
||||
'activeMenu' => 'settings',
|
||||
'activeSettings' => 'apaczka',
|
||||
'user' => $this->auth->user(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
'settings' => $settings,
|
||||
'errorMessage' => (string) Flash::get('settings_error', ''),
|
||||
'successMessage' => (string) Flash::get('settings_success', ''),
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
if (!Csrf::validate((string) $request->input('_token', ''))) {
|
||||
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
|
||||
return Response::redirect('/settings/integrations/apaczka');
|
||||
}
|
||||
|
||||
$apiKey = trim((string) $request->input('api_key', ''));
|
||||
if ($apiKey === '') {
|
||||
Flash::set('settings_error', $this->translator->get('settings.apaczka.validation.api_key_required'));
|
||||
return Response::redirect('/settings/integrations/apaczka');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->repository->saveSettings([
|
||||
'api_key' => $apiKey,
|
||||
]);
|
||||
Flash::set('settings_success', $this->translator->get('settings.apaczka.flash.saved'));
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set(
|
||||
'settings_error',
|
||||
$this->translator->get('settings.apaczka.flash.save_failed') . ' ' . $exception->getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/integrations/apaczka');
|
||||
}
|
||||
}
|
||||
124
src/Modules/Settings/ApaczkaIntegrationRepository.php
Normal file
124
src/Modules/Settings/ApaczkaIntegrationRepository.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class ApaczkaIntegrationRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly string $secret
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getSettings(): array
|
||||
{
|
||||
$row = $this->fetchRow();
|
||||
if ($row === null) {
|
||||
return $this->defaultSettings();
|
||||
}
|
||||
|
||||
return [
|
||||
'has_api_key' => trim((string) ($row['api_key_encrypted'] ?? '')) !== '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function saveSettings(array $payload): void
|
||||
{
|
||||
$this->ensureRow();
|
||||
$current = $this->fetchRow();
|
||||
if ($current === null) {
|
||||
throw new RuntimeException('Brak rekordu konfiguracji Apaczka.');
|
||||
}
|
||||
|
||||
$apiKey = trim((string) ($payload['api_key'] ?? ''));
|
||||
$apiKeyEncrypted = trim((string) ($current['api_key_encrypted'] ?? ''));
|
||||
if ($apiKey !== '') {
|
||||
$apiKeyEncrypted = (string) $this->encrypt($apiKey);
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'UPDATE apaczka_integration_settings
|
||||
SET api_key_encrypted = :api_key_encrypted,
|
||||
updated_at = NOW()
|
||||
WHERE id = 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'api_key_encrypted' => $this->nullableString($apiKeyEncrypted),
|
||||
]);
|
||||
}
|
||||
|
||||
private function ensureRow(): void
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO apaczka_integration_settings (id, created_at, updated_at)
|
||||
VALUES (1, NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE updated_at = VALUES(updated_at)'
|
||||
);
|
||||
$statement->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function fetchRow(): ?array
|
||||
{
|
||||
try {
|
||||
$statement = $this->pdo->prepare('SELECT * FROM apaczka_integration_settings WHERE id = 1 LIMIT 1');
|
||||
$statement->execute();
|
||||
$row = $statement->fetch(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function defaultSettings(): array
|
||||
{
|
||||
return [
|
||||
'has_api_key' => false,
|
||||
];
|
||||
}
|
||||
|
||||
private function nullableString(string $value): ?string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
return $trimmed === '' ? null : $trimmed;
|
||||
}
|
||||
|
||||
private function encrypt(string $plainText): ?string
|
||||
{
|
||||
$value = trim($plainText);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
if ($this->secret === '') {
|
||||
throw new RuntimeException('Brak INTEGRATIONS_SECRET do szyfrowania danych integracji.');
|
||||
}
|
||||
|
||||
$encryptionKey = hash('sha256', 'enc|' . $this->secret, true);
|
||||
$hmacKey = hash('sha256', 'auth|' . $this->secret, true);
|
||||
$iv = random_bytes(16);
|
||||
$cipherRaw = openssl_encrypt($value, 'AES-256-CBC', $encryptionKey, OPENSSL_RAW_DATA, $iv);
|
||||
if ($cipherRaw === false) {
|
||||
throw new RuntimeException('Nie udalo sie zaszyfrowac danych integracji.');
|
||||
}
|
||||
|
||||
$mac = hash_hmac('sha256', $iv . $cipherRaw, $hmacKey, true);
|
||||
return 'v1:' . base64_encode($iv . $mac . $cipherRaw);
|
||||
}
|
||||
}
|
||||
75
src/Modules/Settings/CompanySettingsController.php
Normal file
75
src/Modules/Settings/CompanySettingsController.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use App\Core\Http\Request;
|
||||
use App\Core\Http\Response;
|
||||
use App\Core\I18n\Translator;
|
||||
use App\Core\Security\Csrf;
|
||||
use App\Core\Support\Flash;
|
||||
use App\Core\View\Template;
|
||||
use App\Modules\Auth\AuthService;
|
||||
use Throwable;
|
||||
|
||||
final class CompanySettingsController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Template $template,
|
||||
private readonly Translator $translator,
|
||||
private readonly AuthService $auth,
|
||||
private readonly CompanySettingsRepository $repository
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$html = $this->template->render('settings/company', [
|
||||
'title' => $this->translator->get('settings.company.title'),
|
||||
'activeMenu' => 'settings',
|
||||
'activeSettings' => 'company',
|
||||
'user' => $this->auth->user(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
'settings' => $this->repository->getSettings(),
|
||||
'errorMessage' => (string) Flash::get('settings_error', ''),
|
||||
'successMessage' => (string) Flash::get('settings_success', ''),
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
$token = (string) $request->input('_token', '');
|
||||
if (!Csrf::validate($token)) {
|
||||
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
|
||||
return Response::redirect('/settings/company');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->repository->saveSettings([
|
||||
'company_name' => (string) $request->input('company_name', ''),
|
||||
'person_name' => (string) $request->input('person_name', ''),
|
||||
'street' => (string) $request->input('street', ''),
|
||||
'city' => (string) $request->input('city', ''),
|
||||
'postal_code' => (string) $request->input('postal_code', ''),
|
||||
'country_code' => (string) $request->input('country_code', 'PL'),
|
||||
'phone' => (string) $request->input('phone', ''),
|
||||
'email' => (string) $request->input('email', ''),
|
||||
'tax_number' => (string) $request->input('tax_number', ''),
|
||||
'bank_account' => (string) $request->input('bank_account', ''),
|
||||
'bank_owner_name' => (string) $request->input('bank_owner_name', ''),
|
||||
'default_package_length_cm' => (string) $request->input('default_package_length_cm', '25'),
|
||||
'default_package_width_cm' => (string) $request->input('default_package_width_cm', '20'),
|
||||
'default_package_height_cm' => (string) $request->input('default_package_height_cm', '8'),
|
||||
'default_package_weight_kg' => (string) $request->input('default_package_weight_kg', '1'),
|
||||
'default_label_format' => (string) $request->input('default_label_format', 'PDF'),
|
||||
]);
|
||||
Flash::set('settings_success', $this->translator->get('settings.company.flash.saved'));
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.company.flash.save_failed') . ' ' . $exception->getMessage());
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/company');
|
||||
}
|
||||
}
|
||||
159
src/Modules/Settings/CompanySettingsRepository.php
Normal file
159
src/Modules/Settings/CompanySettingsRepository.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
final class CompanySettingsRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getSettings(): array
|
||||
{
|
||||
try {
|
||||
$statement = $this->pdo->prepare('SELECT * FROM company_settings WHERE id = 1 LIMIT 1');
|
||||
$statement->execute();
|
||||
$row = $statement->fetch(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable) {
|
||||
return $this->defaults();
|
||||
}
|
||||
|
||||
if (!is_array($row)) {
|
||||
return $this->defaults();
|
||||
}
|
||||
|
||||
return [
|
||||
'company_name' => trim((string) ($row['company_name'] ?? '')),
|
||||
'person_name' => trim((string) ($row['person_name'] ?? '')),
|
||||
'street' => trim((string) ($row['street'] ?? '')),
|
||||
'city' => trim((string) ($row['city'] ?? '')),
|
||||
'postal_code' => trim((string) ($row['postal_code'] ?? '')),
|
||||
'country_code' => trim((string) ($row['country_code'] ?? 'PL')),
|
||||
'phone' => trim((string) ($row['phone'] ?? '')),
|
||||
'email' => trim((string) ($row['email'] ?? '')),
|
||||
'tax_number' => trim((string) ($row['tax_number'] ?? '')),
|
||||
'bank_account' => trim((string) ($row['bank_account'] ?? '')),
|
||||
'bank_owner_name' => trim((string) ($row['bank_owner_name'] ?? '')),
|
||||
'default_package_length_cm' => (float) ($row['default_package_length_cm'] ?? 25.0),
|
||||
'default_package_width_cm' => (float) ($row['default_package_width_cm'] ?? 20.0),
|
||||
'default_package_height_cm' => (float) ($row['default_package_height_cm'] ?? 8.0),
|
||||
'default_package_weight_kg' => (float) ($row['default_package_weight_kg'] ?? 1.0),
|
||||
'default_label_format' => trim((string) ($row['default_label_format'] ?? 'PDF')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function saveSettings(array $data): void
|
||||
{
|
||||
$this->ensureRow();
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'UPDATE company_settings SET
|
||||
company_name = :company_name,
|
||||
person_name = :person_name,
|
||||
street = :street,
|
||||
city = :city,
|
||||
postal_code = :postal_code,
|
||||
country_code = :country_code,
|
||||
phone = :phone,
|
||||
email = :email,
|
||||
tax_number = :tax_number,
|
||||
bank_account = :bank_account,
|
||||
bank_owner_name = :bank_owner_name,
|
||||
default_package_length_cm = :length,
|
||||
default_package_width_cm = :width,
|
||||
default_package_height_cm = :height,
|
||||
default_package_weight_kg = :weight,
|
||||
default_label_format = :label_format,
|
||||
updated_at = NOW()
|
||||
WHERE id = 1'
|
||||
);
|
||||
|
||||
$statement->execute([
|
||||
'company_name' => $this->nullableString((string) ($data['company_name'] ?? '')),
|
||||
'person_name' => $this->nullableString((string) ($data['person_name'] ?? '')),
|
||||
'street' => $this->nullableString((string) ($data['street'] ?? '')),
|
||||
'city' => $this->nullableString((string) ($data['city'] ?? '')),
|
||||
'postal_code' => $this->nullableString((string) ($data['postal_code'] ?? '')),
|
||||
'country_code' => strtoupper(trim((string) ($data['country_code'] ?? 'PL'))) ?: 'PL',
|
||||
'phone' => $this->nullableString((string) ($data['phone'] ?? '')),
|
||||
'email' => $this->nullableString((string) ($data['email'] ?? '')),
|
||||
'tax_number' => $this->nullableString((string) ($data['tax_number'] ?? '')),
|
||||
'bank_account' => $this->nullableString((string) ($data['bank_account'] ?? '')),
|
||||
'bank_owner_name' => $this->nullableString((string) ($data['bank_owner_name'] ?? '')),
|
||||
'length' => max(0.1, (float) ($data['default_package_length_cm'] ?? 25.0)),
|
||||
'width' => max(0.1, (float) ($data['default_package_width_cm'] ?? 20.0)),
|
||||
'height' => max(0.1, (float) ($data['default_package_height_cm'] ?? 8.0)),
|
||||
'weight' => max(0.001, (float) ($data['default_package_weight_kg'] ?? 1.0)),
|
||||
'label_format' => in_array(strtoupper(trim((string) ($data['default_label_format'] ?? ''))), ['PDF', 'ZPL'], true)
|
||||
? strtoupper(trim((string) $data['default_label_format']))
|
||||
: 'PDF',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getSenderAddress(): array
|
||||
{
|
||||
$settings = $this->getSettings();
|
||||
return [
|
||||
'name' => $settings['person_name'] !== '' ? $settings['person_name'] : ($settings['company_name'] !== '' ? $settings['company_name'] : null),
|
||||
'company' => $settings['company_name'] !== '' ? $settings['company_name'] : null,
|
||||
'street' => $settings['street'] !== '' ? $settings['street'] : null,
|
||||
'city' => $settings['city'] !== '' ? $settings['city'] : null,
|
||||
'postalCode' => $settings['postal_code'] !== '' ? $settings['postal_code'] : null,
|
||||
'countryCode' => $settings['country_code'] !== '' ? $settings['country_code'] : 'PL',
|
||||
'phone' => $settings['phone'] !== '' ? $settings['phone'] : null,
|
||||
'email' => $settings['email'] !== '' ? $settings['email'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function ensureRow(): void
|
||||
{
|
||||
$this->pdo->exec(
|
||||
'INSERT INTO company_settings (id) VALUES (1) ON DUPLICATE KEY UPDATE updated_at = updated_at'
|
||||
);
|
||||
}
|
||||
|
||||
private function nullableString(string $value): ?string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
return $trimmed === '' ? null : $trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function defaults(): array
|
||||
{
|
||||
return [
|
||||
'company_name' => '',
|
||||
'person_name' => '',
|
||||
'street' => '',
|
||||
'city' => '',
|
||||
'postal_code' => '',
|
||||
'country_code' => 'PL',
|
||||
'phone' => '',
|
||||
'email' => '',
|
||||
'tax_number' => '',
|
||||
'bank_account' => '',
|
||||
'bank_owner_name' => '',
|
||||
'default_package_length_cm' => 25.0,
|
||||
'default_package_width_cm' => 20.0,
|
||||
'default_package_height_cm' => 8.0,
|
||||
'default_package_weight_kg' => 1.0,
|
||||
'default_label_format' => 'PDF',
|
||||
];
|
||||
}
|
||||
}
|
||||
77
src/Modules/Settings/InpostIntegrationController.php
Normal file
77
src/Modules/Settings/InpostIntegrationController.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use App\Core\Http\Request;
|
||||
use App\Core\Http\Response;
|
||||
use App\Core\I18n\Translator;
|
||||
use App\Core\Security\Csrf;
|
||||
use App\Core\Support\Flash;
|
||||
use App\Core\View\Template;
|
||||
use App\Modules\Auth\AuthService;
|
||||
use Throwable;
|
||||
|
||||
final class InpostIntegrationController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Template $template,
|
||||
private readonly Translator $translator,
|
||||
private readonly AuthService $auth,
|
||||
private readonly InpostIntegrationRepository $repository
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$settings = $this->repository->getSettings();
|
||||
|
||||
$html = $this->template->render('settings/inpost', [
|
||||
'title' => $this->translator->get('settings.inpost.title'),
|
||||
'activeMenu' => 'settings',
|
||||
'activeSettings' => 'inpost',
|
||||
'user' => $this->auth->user(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
'settings' => $settings,
|
||||
'errorMessage' => (string) Flash::get('settings_error', ''),
|
||||
'successMessage' => (string) Flash::get('settings_success', ''),
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
if (!Csrf::validate((string) $request->input('_token', ''))) {
|
||||
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
|
||||
return Response::redirect('/settings/integrations/inpost');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->repository->saveSettings([
|
||||
'api_token' => trim((string) $request->input('api_token', '')),
|
||||
'organization_id' => trim((string) $request->input('organization_id', '')),
|
||||
'environment' => trim((string) $request->input('environment', 'sandbox')),
|
||||
'default_dispatch_method' => trim((string) $request->input('default_dispatch_method', 'pop')),
|
||||
'default_dispatch_point' => trim((string) $request->input('default_dispatch_point', '')),
|
||||
'default_insurance' => $request->input('default_insurance', ''),
|
||||
'default_locker_size' => trim((string) $request->input('default_locker_size', 'small')),
|
||||
'default_courier_length' => $request->input('default_courier_length', 20),
|
||||
'default_courier_width' => $request->input('default_courier_width', 15),
|
||||
'default_courier_height' => $request->input('default_courier_height', 8),
|
||||
'label_format' => trim((string) $request->input('label_format', 'Pdf')),
|
||||
'weekend_delivery' => $request->input('weekend_delivery', ''),
|
||||
'auto_insurance_value' => $request->input('auto_insurance_value', ''),
|
||||
'multi_parcel' => $request->input('multi_parcel', ''),
|
||||
]);
|
||||
Flash::set('settings_success', $this->translator->get('settings.inpost.flash.saved'));
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set(
|
||||
'settings_error',
|
||||
$this->translator->get('settings.inpost.flash.save_failed') . ' ' . $exception->getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/integrations/inpost');
|
||||
}
|
||||
}
|
||||
233
src/Modules/Settings/InpostIntegrationRepository.php
Normal file
233
src/Modules/Settings/InpostIntegrationRepository.php
Normal file
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class InpostIntegrationRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly string $secret
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getSettings(): array
|
||||
{
|
||||
$row = $this->fetchRow();
|
||||
if ($row === null) {
|
||||
return $this->defaultSettings();
|
||||
}
|
||||
|
||||
return [
|
||||
'has_api_token' => trim((string) ($row['api_token_encrypted'] ?? '')) !== '',
|
||||
'organization_id' => (string) ($row['organization_id'] ?? ''),
|
||||
'environment' => (string) ($row['environment'] ?? 'sandbox'),
|
||||
'default_dispatch_method' => (string) ($row['default_dispatch_method'] ?? 'pop'),
|
||||
'default_dispatch_point' => (string) ($row['default_dispatch_point'] ?? ''),
|
||||
'default_insurance' => $row['default_insurance'] !== null ? (float) $row['default_insurance'] : null,
|
||||
'default_locker_size' => (string) ($row['default_locker_size'] ?? 'small'),
|
||||
'default_courier_length' => (int) ($row['default_courier_length'] ?? 20),
|
||||
'default_courier_width' => (int) ($row['default_courier_width'] ?? 15),
|
||||
'default_courier_height' => (int) ($row['default_courier_height'] ?? 8),
|
||||
'label_format' => (string) ($row['label_format'] ?? 'Pdf'),
|
||||
'weekend_delivery' => (bool) ($row['weekend_delivery'] ?? false),
|
||||
'auto_insurance_value' => (bool) ($row['auto_insurance_value'] ?? false),
|
||||
'multi_parcel' => (bool) ($row['multi_parcel'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function saveSettings(array $payload): void
|
||||
{
|
||||
$this->ensureRow();
|
||||
$current = $this->fetchRow();
|
||||
if ($current === null) {
|
||||
throw new RuntimeException('Brak rekordu konfiguracji InPost.');
|
||||
}
|
||||
|
||||
$apiToken = trim((string) ($payload['api_token'] ?? ''));
|
||||
$apiTokenEncrypted = trim((string) ($current['api_token_encrypted'] ?? ''));
|
||||
if ($apiToken !== '') {
|
||||
$apiTokenEncrypted = (string) $this->encrypt($apiToken);
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'UPDATE inpost_integration_settings
|
||||
SET api_token_encrypted = :api_token_encrypted,
|
||||
organization_id = :organization_id,
|
||||
environment = :environment,
|
||||
default_dispatch_method = :default_dispatch_method,
|
||||
default_dispatch_point = :default_dispatch_point,
|
||||
default_insurance = :default_insurance,
|
||||
default_locker_size = :default_locker_size,
|
||||
default_courier_length = :default_courier_length,
|
||||
default_courier_width = :default_courier_width,
|
||||
default_courier_height = :default_courier_height,
|
||||
label_format = :label_format,
|
||||
weekend_delivery = :weekend_delivery,
|
||||
auto_insurance_value = :auto_insurance_value,
|
||||
multi_parcel = :multi_parcel,
|
||||
updated_at = NOW()
|
||||
WHERE id = 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'api_token_encrypted' => $this->nullableString($apiTokenEncrypted),
|
||||
'organization_id' => $this->nullableString(trim((string) ($payload['organization_id'] ?? ''))),
|
||||
'environment' => in_array($payload['environment'] ?? '', ['sandbox', 'production'], true)
|
||||
? $payload['environment']
|
||||
: 'sandbox',
|
||||
'default_dispatch_method' => in_array($payload['default_dispatch_method'] ?? '', ['pop', 'parcel_locker', 'courier'], true)
|
||||
? $payload['default_dispatch_method']
|
||||
: 'pop',
|
||||
'default_dispatch_point' => $this->nullableString(trim((string) ($payload['default_dispatch_point'] ?? ''))),
|
||||
'default_insurance' => ($payload['default_insurance'] ?? '') !== ''
|
||||
? (float) $payload['default_insurance']
|
||||
: null,
|
||||
'default_locker_size' => in_array($payload['default_locker_size'] ?? '', ['small', 'medium', 'large'], true)
|
||||
? $payload['default_locker_size']
|
||||
: 'small',
|
||||
'default_courier_length' => max(1, (int) ($payload['default_courier_length'] ?? 20)),
|
||||
'default_courier_width' => max(1, (int) ($payload['default_courier_width'] ?? 15)),
|
||||
'default_courier_height' => max(1, (int) ($payload['default_courier_height'] ?? 8)),
|
||||
'label_format' => in_array($payload['label_format'] ?? '', ['Pdf', 'Zpl', 'Epl'], true)
|
||||
? $payload['label_format']
|
||||
: 'Pdf',
|
||||
'weekend_delivery' => !empty($payload['weekend_delivery']) ? 1 : 0,
|
||||
'auto_insurance_value' => !empty($payload['auto_insurance_value']) ? 1 : 0,
|
||||
'multi_parcel' => !empty($payload['multi_parcel']) ? 1 : 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|null Decrypted API token or null
|
||||
*/
|
||||
public function getDecryptedToken(): ?string
|
||||
{
|
||||
$row = $this->fetchRow();
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$encrypted = trim((string) ($row['api_token_encrypted'] ?? ''));
|
||||
if ($encrypted === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->decrypt($encrypted);
|
||||
}
|
||||
|
||||
private function ensureRow(): void
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO inpost_integration_settings (id, created_at, updated_at)
|
||||
VALUES (1, NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE updated_at = VALUES(updated_at)'
|
||||
);
|
||||
$statement->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function fetchRow(): ?array
|
||||
{
|
||||
try {
|
||||
$statement = $this->pdo->prepare('SELECT * FROM inpost_integration_settings WHERE id = 1 LIMIT 1');
|
||||
$statement->execute();
|
||||
$row = $statement->fetch(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function defaultSettings(): array
|
||||
{
|
||||
return [
|
||||
'has_api_token' => false,
|
||||
'organization_id' => '',
|
||||
'environment' => 'sandbox',
|
||||
'default_dispatch_method' => 'pop',
|
||||
'default_dispatch_point' => '',
|
||||
'default_insurance' => null,
|
||||
'default_locker_size' => 'small',
|
||||
'default_courier_length' => 20,
|
||||
'default_courier_width' => 15,
|
||||
'default_courier_height' => 8,
|
||||
'label_format' => 'Pdf',
|
||||
'weekend_delivery' => false,
|
||||
'auto_insurance_value' => false,
|
||||
'multi_parcel' => false,
|
||||
];
|
||||
}
|
||||
|
||||
private function nullableString(string $value): ?string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
return $trimmed === '' ? null : $trimmed;
|
||||
}
|
||||
|
||||
private function encrypt(string $plainText): ?string
|
||||
{
|
||||
$value = trim($plainText);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
if ($this->secret === '') {
|
||||
throw new RuntimeException('Brak INTEGRATIONS_SECRET do szyfrowania danych integracji.');
|
||||
}
|
||||
|
||||
$encryptionKey = hash('sha256', 'enc|' . $this->secret, true);
|
||||
$hmacKey = hash('sha256', 'auth|' . $this->secret, true);
|
||||
$iv = random_bytes(16);
|
||||
$cipherRaw = openssl_encrypt($value, 'AES-256-CBC', $encryptionKey, OPENSSL_RAW_DATA, $iv);
|
||||
if ($cipherRaw === false) {
|
||||
throw new RuntimeException('Nie udalo sie zaszyfrowac danych integracji.');
|
||||
}
|
||||
|
||||
$mac = hash_hmac('sha256', $iv . $cipherRaw, $hmacKey, true);
|
||||
return 'v1:' . base64_encode($iv . $mac . $cipherRaw);
|
||||
}
|
||||
|
||||
private function decrypt(string $encrypted): ?string
|
||||
{
|
||||
if ($this->secret === '') {
|
||||
throw new RuntimeException('Brak INTEGRATIONS_SECRET do odszyfrowania danych integracji.');
|
||||
}
|
||||
if (!str_starts_with($encrypted, 'v1:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = base64_decode(substr($encrypted, 3), true);
|
||||
if ($raw === false || strlen($raw) < 48) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$encryptionKey = hash('sha256', 'enc|' . $this->secret, true);
|
||||
$hmacKey = hash('sha256', 'auth|' . $this->secret, true);
|
||||
$iv = substr($raw, 0, 16);
|
||||
$mac = substr($raw, 16, 32);
|
||||
$cipherRaw = substr($raw, 48);
|
||||
|
||||
$expectedMac = hash_hmac('sha256', $iv . $cipherRaw, $hmacKey, true);
|
||||
if (!hash_equals($expectedMac, $mac)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decrypted = openssl_decrypt($cipherRaw, 'AES-256-CBC', $encryptionKey, OPENSSL_RAW_DATA, $iv);
|
||||
return $decrypted !== false ? $decrypted : null;
|
||||
}
|
||||
}
|
||||
437
src/Modules/Shipments/AllegroShipmentService.php
Normal file
437
src/Modules/Shipments/AllegroShipmentService.php
Normal file
@@ -0,0 +1,437 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Shipments;
|
||||
|
||||
use App\Modules\Orders\OrdersRepository;
|
||||
use App\Modules\Settings\AllegroApiClient;
|
||||
use App\Modules\Settings\AllegroIntegrationRepository;
|
||||
use App\Modules\Settings\AllegroOAuthClient;
|
||||
use App\Modules\Settings\CompanySettingsRepository;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class AllegroShipmentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AllegroIntegrationRepository $integrationRepository,
|
||||
private readonly AllegroOAuthClient $oauthClient,
|
||||
private readonly AllegroApiClient $apiClient,
|
||||
private readonly ShipmentPackageRepository $packages,
|
||||
private readonly CompanySettingsRepository $companySettings,
|
||||
private readonly OrdersRepository $ordersRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getDeliveryServices(): array
|
||||
{
|
||||
[$accessToken, $env] = $this->resolveToken();
|
||||
$response = $this->apiClient->getDeliveryServices($env, $accessToken);
|
||||
return is_array($response['services'] ?? null) ? $response['services'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $formData
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createShipment(int $orderId, array $formData): array
|
||||
{
|
||||
$order = $this->ordersRepository->findDetails($orderId);
|
||||
if ($order === null) {
|
||||
throw new RuntimeException('Zamowienie nie znalezione.');
|
||||
}
|
||||
|
||||
$company = $this->companySettings->getSettings();
|
||||
$sender = $this->companySettings->getSenderAddress();
|
||||
$this->validateSenderAddress($sender);
|
||||
|
||||
$deliveryMethodId = trim((string) ($formData['delivery_method_id'] ?? ''));
|
||||
if ($deliveryMethodId === '') {
|
||||
throw new RuntimeException('Nie podano metody dostawy.');
|
||||
}
|
||||
|
||||
$receiverAddress = $this->buildReceiverAddress($order, $formData);
|
||||
$senderAddress = $sender;
|
||||
if (trim((string) ($formData['sender_point_id'] ?? '')) !== '') {
|
||||
$senderAddress['point'] = trim((string) $formData['sender_point_id']);
|
||||
}
|
||||
|
||||
$packageType = strtoupper(trim((string) ($formData['package_type'] ?? 'PACKAGE')));
|
||||
$lengthCm = (float) ($formData['length_cm'] ?? $company['default_package_length_cm']);
|
||||
$widthCm = (float) ($formData['width_cm'] ?? $company['default_package_width_cm']);
|
||||
$heightCm = (float) ($formData['height_cm'] ?? $company['default_package_height_cm']);
|
||||
$weightKg = (float) ($formData['weight_kg'] ?? $company['default_package_weight_kg']);
|
||||
$labelFormat = strtoupper(trim((string) ($formData['label_format'] ?? $company['default_label_format'])));
|
||||
if (!in_array($labelFormat, ['PDF', 'ZPL'], true)) {
|
||||
$labelFormat = 'PDF';
|
||||
}
|
||||
|
||||
$orderData = is_array($order['order'] ?? null) ? $order['order'] : [];
|
||||
$sourceOrderId = trim((string) ($orderData['source_order_id'] ?? ''));
|
||||
|
||||
$commandId = $this->generateUuid();
|
||||
|
||||
$apiPayload = [
|
||||
'commandId' => $commandId,
|
||||
'input' => [
|
||||
'deliveryMethodId' => $deliveryMethodId,
|
||||
'sender' => $senderAddress,
|
||||
'receiver' => $receiverAddress,
|
||||
'referenceNumber' => $sourceOrderId !== '' ? $sourceOrderId : (string) $orderId,
|
||||
'packages' => [[
|
||||
'type' => $packageType,
|
||||
'length' => ['value' => $lengthCm, 'unit' => 'CENTIMETER'],
|
||||
'width' => ['value' => $widthCm, 'unit' => 'CENTIMETER'],
|
||||
'height' => ['value' => $heightCm, 'unit' => 'CENTIMETER'],
|
||||
'weight' => ['value' => $weightKg, 'unit' => 'KILOGRAMS'],
|
||||
]],
|
||||
'labelFormat' => $labelFormat,
|
||||
],
|
||||
];
|
||||
|
||||
$insuranceAmount = (float) ($formData['insurance_amount'] ?? 0);
|
||||
if ($insuranceAmount > 0) {
|
||||
$apiPayload['input']['insurance'] = [
|
||||
'amount' => number_format($insuranceAmount, 2, '.', ''),
|
||||
'currency' => strtoupper(trim((string) ($formData['insurance_currency'] ?? 'PLN'))),
|
||||
];
|
||||
}
|
||||
|
||||
$codAmount = (float) ($formData['cod_amount'] ?? 0);
|
||||
if ($codAmount > 0) {
|
||||
$cod = [
|
||||
'amount' => number_format($codAmount, 2, '.', ''),
|
||||
'currency' => strtoupper(trim((string) ($formData['cod_currency'] ?? 'PLN'))),
|
||||
];
|
||||
if (trim($company['bank_owner_name']) !== '') {
|
||||
$cod['ownerName'] = $company['bank_owner_name'];
|
||||
}
|
||||
if (trim($company['bank_account']) !== '') {
|
||||
$cod['iban'] = $company['bank_account'];
|
||||
}
|
||||
$apiPayload['input']['cashOnDelivery'] = $cod;
|
||||
}
|
||||
|
||||
$credentialsId = trim((string) ($formData['credentials_id'] ?? ''));
|
||||
if ($credentialsId !== '') {
|
||||
$apiPayload['input']['credentialsId'] = $credentialsId;
|
||||
}
|
||||
|
||||
$packageId = $this->packages->create([
|
||||
'order_id' => $orderId,
|
||||
'provider' => 'allegro_wza',
|
||||
'delivery_method_id' => $deliveryMethodId,
|
||||
'credentials_id' => $credentialsId !== '' ? $credentialsId : null,
|
||||
'command_id' => $commandId,
|
||||
'status' => 'pending',
|
||||
'carrier_id' => trim((string) ($formData['carrier_id'] ?? '')),
|
||||
'package_type' => $packageType,
|
||||
'weight_kg' => $weightKg,
|
||||
'length_cm' => $lengthCm,
|
||||
'width_cm' => $widthCm,
|
||||
'height_cm' => $heightCm,
|
||||
'insurance_amount' => $insuranceAmount > 0 ? $insuranceAmount : null,
|
||||
'insurance_currency' => $insuranceAmount > 0 ? strtoupper(trim((string) ($formData['insurance_currency'] ?? 'PLN'))) : null,
|
||||
'cod_amount' => $codAmount > 0 ? $codAmount : null,
|
||||
'cod_currency' => $codAmount > 0 ? strtoupper(trim((string) ($formData['cod_currency'] ?? 'PLN'))) : null,
|
||||
'label_format' => $labelFormat,
|
||||
'receiver_point_id' => trim((string) ($formData['receiver_point_id'] ?? '')),
|
||||
'sender_point_id' => trim((string) ($formData['sender_point_id'] ?? '')),
|
||||
'reference_number' => $sourceOrderId !== '' ? $sourceOrderId : (string) $orderId,
|
||||
'payload_json' => $apiPayload,
|
||||
]);
|
||||
|
||||
[$accessToken, $env] = $this->resolveToken();
|
||||
|
||||
try {
|
||||
$response = $this->apiClient->createShipment($env, $accessToken, $apiPayload);
|
||||
} catch (RuntimeException $exception) {
|
||||
if (trim($exception->getMessage()) === 'ALLEGRO_HTTP_401') {
|
||||
[$accessToken, $env] = $this->forceRefreshToken();
|
||||
$response = $this->apiClient->createShipment($env, $accessToken, $apiPayload);
|
||||
} else {
|
||||
$this->packages->update($packageId, [
|
||||
'status' => 'error',
|
||||
'error_message' => $exception->getMessage(),
|
||||
]);
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
$returnedCommandId = trim((string) ($response['commandId'] ?? $commandId));
|
||||
$this->packages->update($packageId, [
|
||||
'command_id' => $returnedCommandId,
|
||||
'payload_json' => $response,
|
||||
]);
|
||||
|
||||
return [
|
||||
'package_id' => $packageId,
|
||||
'command_id' => $returnedCommandId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function checkCreationStatus(int $packageId): array
|
||||
{
|
||||
$package = $this->packages->findById($packageId);
|
||||
if ($package === null) {
|
||||
throw new RuntimeException('Paczka nie znaleziona.');
|
||||
}
|
||||
|
||||
$commandId = trim((string) ($package['command_id'] ?? ''));
|
||||
if ($commandId === '') {
|
||||
throw new RuntimeException('Brak command_id dla tej paczki.');
|
||||
}
|
||||
|
||||
[$accessToken, $env] = $this->resolveToken();
|
||||
$response = $this->apiClient->getShipmentCreationStatus($env, $accessToken, $commandId);
|
||||
|
||||
$status = strtoupper(trim((string) ($response['status'] ?? '')));
|
||||
$shipmentId = trim((string) ($response['shipmentId'] ?? ''));
|
||||
|
||||
if ($status === 'SUCCESS' && $shipmentId !== '') {
|
||||
$details = $this->apiClient->getShipmentDetails($env, $accessToken, $shipmentId);
|
||||
$trackingNumber = trim((string) ($details['waybill'] ?? ''));
|
||||
|
||||
$this->packages->update($packageId, [
|
||||
'status' => 'created',
|
||||
'shipment_id' => $shipmentId,
|
||||
'tracking_number' => $trackingNumber !== '' ? $trackingNumber : null,
|
||||
'payload_json' => $details,
|
||||
]);
|
||||
|
||||
return [
|
||||
'status' => 'created',
|
||||
'shipment_id' => $shipmentId,
|
||||
'tracking_number' => $trackingNumber,
|
||||
];
|
||||
}
|
||||
|
||||
if ($status === 'ERROR') {
|
||||
$errors = is_array($response['errors'] ?? null) ? $response['errors'] : [];
|
||||
$messages = [];
|
||||
foreach ($errors as $err) {
|
||||
if (is_array($err)) {
|
||||
$messages[] = trim((string) ($err['message'] ?? ($err['userMessage'] ?? '')));
|
||||
}
|
||||
}
|
||||
$errorMsg = implode('; ', array_filter($messages)) ?: 'Blad tworzenia przesylki.';
|
||||
|
||||
$this->packages->update($packageId, [
|
||||
'status' => 'error',
|
||||
'error_message' => $errorMsg,
|
||||
'payload_json' => $response,
|
||||
]);
|
||||
|
||||
return ['status' => 'error', 'error' => $errorMsg];
|
||||
}
|
||||
|
||||
return ['status' => 'in_progress'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function downloadLabel(int $packageId, string $storagePath): array
|
||||
{
|
||||
$package = $this->packages->findById($packageId);
|
||||
if ($package === null) {
|
||||
throw new RuntimeException('Paczka nie znaleziona.');
|
||||
}
|
||||
|
||||
$shipmentId = trim((string) ($package['shipment_id'] ?? ''));
|
||||
if ($shipmentId === '') {
|
||||
throw new RuntimeException('Przesylka nie zostala jeszcze utworzona.');
|
||||
}
|
||||
|
||||
[$accessToken, $env] = $this->resolveToken();
|
||||
$labelFormat = trim((string) ($package['label_format'] ?? 'PDF'));
|
||||
$pageSize = $labelFormat === 'ZPL' ? 'A6' : 'A6';
|
||||
$binary = $this->apiClient->getShipmentLabel($env, $accessToken, [$shipmentId], $pageSize);
|
||||
|
||||
$dir = rtrim($storagePath, '/\\') . '/labels';
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0775, true);
|
||||
}
|
||||
|
||||
$ext = $labelFormat === 'ZPL' ? 'zpl' : 'pdf';
|
||||
$filename = 'label_' . $packageId . '_' . $shipmentId . '.' . $ext;
|
||||
$filePath = $dir . '/' . $filename;
|
||||
file_put_contents($filePath, $binary);
|
||||
|
||||
$relativePath = 'labels/' . $filename;
|
||||
$this->packages->update($packageId, [
|
||||
'status' => 'label_ready',
|
||||
'label_path' => $relativePath,
|
||||
]);
|
||||
|
||||
return [
|
||||
'label_path' => $relativePath,
|
||||
'full_path' => $filePath,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $orderDetails
|
||||
* @param array<string, mixed> $formData
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildReceiverAddress(?array $orderDetails, array $formData): array
|
||||
{
|
||||
$addresses = is_array($orderDetails['addresses'] ?? null) ? $orderDetails['addresses'] : [];
|
||||
$deliveryAddr = null;
|
||||
$customerAddr = null;
|
||||
foreach ($addresses as $addr) {
|
||||
$type = (string) ($addr['address_type'] ?? '');
|
||||
if ($type === 'delivery') {
|
||||
$deliveryAddr = $addr;
|
||||
}
|
||||
if ($type === 'customer') {
|
||||
$customerAddr = $addr;
|
||||
}
|
||||
}
|
||||
|
||||
$addr = $deliveryAddr ?? $customerAddr ?? [];
|
||||
|
||||
$email = trim((string) ($formData['receiver_email'] ?? ($addr['email'] ?? '')));
|
||||
$receiver = [
|
||||
'name' => trim((string) ($formData['receiver_name'] ?? ($addr['name'] ?? ''))),
|
||||
'street' => trim((string) ($formData['receiver_street'] ?? ($addr['street_name'] ?? ''))),
|
||||
'city' => trim((string) ($formData['receiver_city'] ?? ($addr['city'] ?? ''))),
|
||||
'postalCode' => trim((string) ($formData['receiver_postal_code'] ?? ($addr['zip_code'] ?? ''))),
|
||||
'countryCode' => strtoupper(trim((string) ($formData['receiver_country_code'] ?? ($addr['country'] ?? 'PL')))),
|
||||
'phone' => trim((string) ($formData['receiver_phone'] ?? ($addr['phone'] ?? ''))),
|
||||
'email' => $email,
|
||||
];
|
||||
|
||||
$buyerEmail = trim((string) ($customerAddr['email'] ?? $email));
|
||||
if ($buyerEmail !== '') {
|
||||
$receiver['hashedMail'] = hash('sha256', strtolower($buyerEmail));
|
||||
}
|
||||
|
||||
$company = trim((string) ($formData['receiver_company'] ?? ($addr['company_name'] ?? '')));
|
||||
if ($company !== '') {
|
||||
$receiver['company'] = $company;
|
||||
}
|
||||
|
||||
$pointId = trim((string) ($formData['receiver_point_id'] ?? ($addr['parcel_external_id'] ?? '')));
|
||||
if ($pointId !== '') {
|
||||
$receiver['point'] = $pointId;
|
||||
}
|
||||
|
||||
return $receiver;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $sender
|
||||
*/
|
||||
private function validateSenderAddress(array $sender): void
|
||||
{
|
||||
$required = ['street', 'city', 'postalCode', 'phone', 'email'];
|
||||
foreach ($required as $field) {
|
||||
if (trim((string) ($sender[$field] ?? '')) === '') {
|
||||
throw new RuntimeException('Uzupelnij dane nadawcy w Ustawienia > Dane firmy (brak: ' . $field . ').');
|
||||
}
|
||||
}
|
||||
|
||||
$name = trim((string) ($sender['name'] ?? ''));
|
||||
$company = trim((string) ($sender['company'] ?? ''));
|
||||
if ($name === '' && $company === '') {
|
||||
throw new RuntimeException('Uzupelnij dane nadawcy w Ustawienia > Dane firmy (brak nazwy/firmy).');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
private function resolveToken(): array
|
||||
{
|
||||
$oauth = $this->integrationRepository->getTokenCredentials();
|
||||
if ($oauth === null) {
|
||||
throw new RuntimeException('Brak polaczenia OAuth Allegro. Polacz konto w Ustawieniach.');
|
||||
}
|
||||
|
||||
$env = (string) ($oauth['environment'] ?? 'sandbox');
|
||||
$accessToken = trim((string) ($oauth['access_token'] ?? ''));
|
||||
$tokenExpiresAt = trim((string) ($oauth['token_expires_at'] ?? ''));
|
||||
|
||||
if ($accessToken === '') {
|
||||
return $this->forceRefreshToken();
|
||||
}
|
||||
|
||||
if ($tokenExpiresAt !== '') {
|
||||
try {
|
||||
$expiresAt = new DateTimeImmutable($tokenExpiresAt);
|
||||
if ($expiresAt <= (new DateTimeImmutable('now'))->add(new DateInterval('PT5M'))) {
|
||||
return $this->forceRefreshToken();
|
||||
}
|
||||
} catch (Throwable) {
|
||||
return $this->forceRefreshToken();
|
||||
}
|
||||
}
|
||||
|
||||
return [$accessToken, $env];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
private function forceRefreshToken(): array
|
||||
{
|
||||
$oauth = $this->integrationRepository->getTokenCredentials();
|
||||
if ($oauth === null) {
|
||||
throw new RuntimeException('Brak danych OAuth Allegro.');
|
||||
}
|
||||
|
||||
$token = $this->oauthClient->refreshAccessToken(
|
||||
(string) ($oauth['environment'] ?? 'sandbox'),
|
||||
(string) ($oauth['client_id'] ?? ''),
|
||||
(string) ($oauth['client_secret'] ?? ''),
|
||||
(string) ($oauth['refresh_token'] ?? '')
|
||||
);
|
||||
|
||||
$expiresAt = null;
|
||||
$expiresIn = max(0, (int) ($token['expires_in'] ?? 0));
|
||||
if ($expiresIn > 0) {
|
||||
$expiresAt = (new DateTimeImmutable('now'))
|
||||
->add(new DateInterval('PT' . $expiresIn . 'S'))
|
||||
->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$refreshToken = trim((string) ($token['refresh_token'] ?? ''));
|
||||
if ($refreshToken === '') {
|
||||
$refreshToken = (string) ($oauth['refresh_token'] ?? '');
|
||||
}
|
||||
|
||||
$this->integrationRepository->saveTokens(
|
||||
(string) ($token['access_token'] ?? ''),
|
||||
$refreshToken,
|
||||
(string) ($token['token_type'] ?? ''),
|
||||
(string) ($token['scope'] ?? ''),
|
||||
$expiresAt
|
||||
);
|
||||
|
||||
$updated = $this->integrationRepository->getTokenCredentials();
|
||||
$newToken = trim((string) ($updated['access_token'] ?? ''));
|
||||
if ($newToken === '') {
|
||||
throw new RuntimeException('Nie udalo sie odswiezyc tokenu Allegro.');
|
||||
}
|
||||
|
||||
return [$newToken, (string) ($updated['environment'] ?? 'sandbox')];
|
||||
}
|
||||
|
||||
private function generateUuid(): string
|
||||
{
|
||||
$data = random_bytes(16);
|
||||
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
|
||||
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
||||
}
|
||||
}
|
||||
230
src/Modules/Shipments/ShipmentController.php
Normal file
230
src/Modules/Shipments/ShipmentController.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Shipments;
|
||||
|
||||
use App\Core\Http\Request;
|
||||
use App\Core\Http\Response;
|
||||
use App\Core\I18n\Translator;
|
||||
use App\Core\Security\Csrf;
|
||||
use App\Core\View\Template;
|
||||
use App\Modules\Auth\AuthService;
|
||||
use App\Modules\Orders\OrdersRepository;
|
||||
use App\Modules\Settings\AllegroDeliveryMethodMappingRepository;
|
||||
use App\Modules\Settings\CompanySettingsRepository;
|
||||
use Throwable;
|
||||
|
||||
final class ShipmentController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Template $template,
|
||||
private readonly Translator $translator,
|
||||
private readonly AuthService $auth,
|
||||
private readonly OrdersRepository $ordersRepository,
|
||||
private readonly CompanySettingsRepository $companySettings,
|
||||
private readonly AllegroShipmentService $shipmentService,
|
||||
private readonly ShipmentPackageRepository $packageRepository,
|
||||
private readonly string $storagePath,
|
||||
private readonly ?AllegroDeliveryMethodMappingRepository $deliveryMappings = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function prepare(Request $request): Response
|
||||
{
|
||||
$orderId = max(0, (int) $request->input('id', 0));
|
||||
$details = $this->ordersRepository->findDetails($orderId);
|
||||
if ($details === null) {
|
||||
return Response::html('Not found', 404);
|
||||
}
|
||||
|
||||
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
|
||||
$addresses = is_array($details['addresses'] ?? null) ? $details['addresses'] : [];
|
||||
$items = is_array($details['items'] ?? null) ? $details['items'] : [];
|
||||
$company = $this->companySettings->getSettings();
|
||||
$existingPackages = $this->packageRepository->findByOrderId($orderId);
|
||||
|
||||
$deliveryAddr = null;
|
||||
$customerAddr = null;
|
||||
foreach ($addresses as $addr) {
|
||||
$type = (string) ($addr['address_type'] ?? '');
|
||||
if ($type === 'delivery') {
|
||||
$deliveryAddr = $addr;
|
||||
}
|
||||
if ($type === 'customer') {
|
||||
$customerAddr = $addr;
|
||||
}
|
||||
}
|
||||
|
||||
$receiverAddr = $deliveryAddr ?? $customerAddr ?? [];
|
||||
$preferences = is_array($order['preferences_json'] ?? null)
|
||||
? $order['preferences_json']
|
||||
: (is_string($order['preferences_json'] ?? null) ? (json_decode($order['preferences_json'], true) ?: []) : []);
|
||||
|
||||
$deliveryServices = [];
|
||||
$deliveryServicesError = '';
|
||||
try {
|
||||
$deliveryServices = $this->shipmentService->getDeliveryServices();
|
||||
} catch (Throwable $exception) {
|
||||
$deliveryServicesError = $exception->getMessage();
|
||||
}
|
||||
|
||||
$flashSuccess = (string) ($_SESSION['shipment_flash_success'] ?? '');
|
||||
$flashError = (string) ($_SESSION['shipment_flash_error'] ?? '');
|
||||
unset($_SESSION['shipment_flash_success'], $_SESSION['shipment_flash_error']);
|
||||
|
||||
$deliveryMapping = null;
|
||||
$orderCarrierName = trim((string) ($order['external_carrier_id'] ?? ''));
|
||||
if ($orderCarrierName !== '' && $this->deliveryMappings !== null) {
|
||||
$deliveryMapping = $this->deliveryMappings->findByOrderMethod($orderCarrierName);
|
||||
}
|
||||
|
||||
$html = $this->template->render('shipments/prepare', [
|
||||
'title' => $this->translator->get('shipments.prepare.title') . ' #' . $orderId,
|
||||
'activeMenu' => 'orders',
|
||||
'activeOrders' => 'list',
|
||||
'user' => $this->auth->user(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
'orderId' => $orderId,
|
||||
'order' => $order,
|
||||
'items' => $items,
|
||||
'receiverAddr' => $receiverAddr,
|
||||
'preferences' => $preferences,
|
||||
'company' => $company,
|
||||
'deliveryServices' => $deliveryServices,
|
||||
'deliveryServicesError' => $deliveryServicesError,
|
||||
'existingPackages' => $existingPackages,
|
||||
'flashSuccess' => $flashSuccess,
|
||||
'flashError' => $flashError,
|
||||
'deliveryMapping' => $deliveryMapping,
|
||||
'inpostServices' => $this->inpostServicesList(),
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id: string, name: string}>
|
||||
*/
|
||||
private function inpostServicesList(): array
|
||||
{
|
||||
return [
|
||||
['id' => 'inpost_locker_standard', 'name' => 'Paczkomat Standard'],
|
||||
['id' => 'inpost_locker_economy', 'name' => 'Paczkomat Economy'],
|
||||
['id' => 'inpost_locker_allegro', 'name' => 'Allegro Paczkomat InPost'],
|
||||
['id' => 'inpost_courier_standard', 'name' => 'Kurier InPost'],
|
||||
['id' => 'inpost_courier_express_1000', 'name' => 'Kurier InPost Express 10:00'],
|
||||
['id' => 'inpost_courier_express_1200', 'name' => 'Kurier InPost Express 12:00'],
|
||||
['id' => 'inpost_courier_express_1700', 'name' => 'Kurier InPost Express 17:00'],
|
||||
['id' => 'inpost_courier_palette', 'name' => 'Kurier InPost Paleta'],
|
||||
['id' => 'inpost_courier_c2c', 'name' => 'Kurier InPost C2C'],
|
||||
['id' => 'inpost_courier_local_standard', 'name' => 'Kurier InPost Lokalny'],
|
||||
['id' => 'inpost_courier_local_express', 'name' => 'Kurier InPost Lokalny Express'],
|
||||
];
|
||||
}
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
$orderId = max(0, (int) $request->input('id', 0));
|
||||
if ($orderId <= 0) {
|
||||
return Response::html('Not found', 404);
|
||||
}
|
||||
|
||||
$csrfToken = (string) $request->input('_csrf_token', '');
|
||||
if (!Csrf::validate($csrfToken)) {
|
||||
$_SESSION['shipment_flash_error'] = $this->translator->get('auth.errors.csrf_expired');
|
||||
return Response::redirect('/orders/' . $orderId . '/shipment/prepare');
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->shipmentService->createShipment($orderId, [
|
||||
'delivery_method_id' => (string) $request->input('delivery_method_id', ''),
|
||||
'credentials_id' => (string) $request->input('credentials_id', ''),
|
||||
'carrier_id' => (string) $request->input('carrier_id', ''),
|
||||
'package_type' => (string) $request->input('package_type', 'PACKAGE'),
|
||||
'length_cm' => (string) $request->input('length_cm', ''),
|
||||
'width_cm' => (string) $request->input('width_cm', ''),
|
||||
'height_cm' => (string) $request->input('height_cm', ''),
|
||||
'weight_kg' => (string) $request->input('weight_kg', ''),
|
||||
'insurance_amount' => (string) $request->input('insurance_amount', '0'),
|
||||
'insurance_currency' => (string) $request->input('insurance_currency', 'PLN'),
|
||||
'cod_amount' => (string) $request->input('cod_amount', '0'),
|
||||
'cod_currency' => (string) $request->input('cod_currency', 'PLN'),
|
||||
'label_format' => (string) $request->input('label_format', 'PDF'),
|
||||
'receiver_name' => (string) $request->input('receiver_name', ''),
|
||||
'receiver_company' => (string) $request->input('receiver_company', ''),
|
||||
'receiver_street' => (string) $request->input('receiver_street', ''),
|
||||
'receiver_city' => (string) $request->input('receiver_city', ''),
|
||||
'receiver_postal_code' => (string) $request->input('receiver_postal_code', ''),
|
||||
'receiver_country_code' => (string) $request->input('receiver_country_code', 'PL'),
|
||||
'receiver_phone' => (string) $request->input('receiver_phone', ''),
|
||||
'receiver_email' => (string) $request->input('receiver_email', ''),
|
||||
'receiver_point_id' => (string) $request->input('receiver_point_id', ''),
|
||||
'sender_point_id' => (string) $request->input('sender_point_id', ''),
|
||||
]);
|
||||
|
||||
$packageId = (int) ($result['package_id'] ?? 0);
|
||||
$_SESSION['shipment_flash_success'] = 'Komenda tworzenia przesylki wyslana. Sprawdz status.';
|
||||
return Response::redirect('/orders/' . $orderId . '/shipment/prepare?check=' . $packageId);
|
||||
} catch (Throwable $exception) {
|
||||
$_SESSION['shipment_flash_error'] = 'Blad tworzenia przesylki: ' . $exception->getMessage();
|
||||
return Response::redirect('/orders/' . $orderId . '/shipment/prepare');
|
||||
}
|
||||
}
|
||||
|
||||
public function checkStatus(Request $request): Response
|
||||
{
|
||||
$orderId = max(0, (int) $request->input('id', 0));
|
||||
$packageId = max(0, (int) $request->input('packageId', 0));
|
||||
if ($orderId <= 0 || $packageId <= 0) {
|
||||
return Response::json(['error' => 'Not found'], 404);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->shipmentService->checkCreationStatus($packageId);
|
||||
return Response::json($result);
|
||||
} catch (Throwable $exception) {
|
||||
return Response::json(['status' => 'error', 'error' => $exception->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function label(Request $request): Response
|
||||
{
|
||||
$orderId = max(0, (int) $request->input('id', 0));
|
||||
$packageId = max(0, (int) $request->input('packageId', 0));
|
||||
if ($orderId <= 0 || $packageId <= 0) {
|
||||
return Response::html('Not found', 404);
|
||||
}
|
||||
|
||||
$csrfToken = (string) $request->input('_csrf_token', '');
|
||||
if (!Csrf::validate($csrfToken)) {
|
||||
$_SESSION['shipment_flash_error'] = $this->translator->get('auth.errors.csrf_expired');
|
||||
return Response::redirect('/orders/' . $orderId . '/shipment/prepare');
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->shipmentService->downloadLabel($packageId, $this->storagePath);
|
||||
$fullPath = (string) ($result['full_path'] ?? '');
|
||||
if ($fullPath !== '' && file_exists($fullPath)) {
|
||||
$package = $this->packageRepository->findById($packageId);
|
||||
$labelFormat = strtoupper(trim((string) ($package['label_format'] ?? 'PDF')));
|
||||
$contentType = $labelFormat === 'ZPL' ? 'application/octet-stream' : 'application/pdf';
|
||||
$filename = basename($fullPath);
|
||||
|
||||
return new Response(
|
||||
(string) file_get_contents($fullPath),
|
||||
200,
|
||||
[
|
||||
'Content-Type' => $contentType,
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$_SESSION['shipment_flash_success'] = 'Etykieta pobrana.';
|
||||
} catch (Throwable $exception) {
|
||||
$_SESSION['shipment_flash_error'] = 'Blad pobierania etykiety: ' . $exception->getMessage();
|
||||
}
|
||||
|
||||
return Response::redirect('/orders/' . $orderId . '/shipment/prepare');
|
||||
}
|
||||
}
|
||||
137
src/Modules/Shipments/ShipmentPackageRepository.php
Normal file
137
src/Modules/Shipments/ShipmentPackageRepository.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Shipments;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class ShipmentPackageRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function create(array $data): int
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO shipment_packages (
|
||||
order_id, provider, delivery_method_id, credentials_id, command_id,
|
||||
status, carrier_id, package_type, weight_kg, length_cm, width_cm, height_cm,
|
||||
insurance_amount, insurance_currency, cod_amount, cod_currency,
|
||||
label_format, receiver_point_id, sender_point_id, reference_number, payload_json
|
||||
) VALUES (
|
||||
:order_id, :provider, :delivery_method_id, :credentials_id, :command_id,
|
||||
:status, :carrier_id, :package_type, :weight_kg, :length_cm, :width_cm, :height_cm,
|
||||
:insurance_amount, :insurance_currency, :cod_amount, :cod_currency,
|
||||
:label_format, :receiver_point_id, :sender_point_id, :reference_number, :payload_json
|
||||
)'
|
||||
);
|
||||
|
||||
$statement->execute([
|
||||
'order_id' => (int) ($data['order_id'] ?? 0),
|
||||
'provider' => trim((string) ($data['provider'] ?? 'allegro_wza')),
|
||||
'delivery_method_id' => $this->nullStr((string) ($data['delivery_method_id'] ?? '')),
|
||||
'credentials_id' => $this->nullStr((string) ($data['credentials_id'] ?? '')),
|
||||
'command_id' => $this->nullStr((string) ($data['command_id'] ?? '')),
|
||||
'status' => trim((string) ($data['status'] ?? 'draft')),
|
||||
'carrier_id' => $this->nullStr((string) ($data['carrier_id'] ?? '')),
|
||||
'package_type' => trim((string) ($data['package_type'] ?? 'PACKAGE')),
|
||||
'weight_kg' => isset($data['weight_kg']) ? (float) $data['weight_kg'] : null,
|
||||
'length_cm' => isset($data['length_cm']) ? (float) $data['length_cm'] : null,
|
||||
'width_cm' => isset($data['width_cm']) ? (float) $data['width_cm'] : null,
|
||||
'height_cm' => isset($data['height_cm']) ? (float) $data['height_cm'] : null,
|
||||
'insurance_amount' => isset($data['insurance_amount']) ? (float) $data['insurance_amount'] : null,
|
||||
'insurance_currency' => $this->nullStr((string) ($data['insurance_currency'] ?? '')),
|
||||
'cod_amount' => isset($data['cod_amount']) ? (float) $data['cod_amount'] : null,
|
||||
'cod_currency' => $this->nullStr((string) ($data['cod_currency'] ?? '')),
|
||||
'label_format' => trim((string) ($data['label_format'] ?? 'PDF')),
|
||||
'receiver_point_id' => $this->nullStr((string) ($data['receiver_point_id'] ?? '')),
|
||||
'sender_point_id' => $this->nullStr((string) ($data['sender_point_id'] ?? '')),
|
||||
'reference_number' => $this->nullStr((string) ($data['reference_number'] ?? '')),
|
||||
'payload_json' => isset($data['payload_json']) ? json_encode($data['payload_json'], JSON_UNESCAPED_UNICODE) : null,
|
||||
]);
|
||||
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function update(int $id, array $data): void
|
||||
{
|
||||
$sets = [];
|
||||
$params = ['id' => $id];
|
||||
|
||||
$allowedFields = [
|
||||
'shipment_id', 'tracking_number', 'status', 'command_id',
|
||||
'label_path', 'error_message',
|
||||
];
|
||||
|
||||
foreach ($allowedFields as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
$sets[] = "$field = :$field";
|
||||
$params[$field] = $data[$field];
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('payload_json', $data)) {
|
||||
$sets[] = 'payload_json = :payload_json';
|
||||
$params['payload_json'] = is_array($data['payload_json'])
|
||||
? json_encode($data['payload_json'], JSON_UNESCAPED_UNICODE)
|
||||
: $data['payload_json'];
|
||||
}
|
||||
|
||||
if ($sets === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sets[] = 'updated_at = NOW()';
|
||||
$sql = 'UPDATE shipment_packages SET ' . implode(', ', $sets) . ' WHERE id = :id';
|
||||
$statement = $this->pdo->prepare($sql);
|
||||
$statement->execute($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
$statement = $this->pdo->prepare('SELECT * FROM shipment_packages WHERE id = :id LIMIT 1');
|
||||
$statement->execute(['id' => $id]);
|
||||
$row = $statement->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function findByOrderId(int $orderId): array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT * FROM shipment_packages WHERE order_id = :order_id ORDER BY created_at DESC'
|
||||
);
|
||||
$statement->execute(['order_id' => $orderId]);
|
||||
return $statement->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findByCommandId(string $commandId): ?array
|
||||
{
|
||||
$statement = $this->pdo->prepare('SELECT * FROM shipment_packages WHERE command_id = :command_id LIMIT 1');
|
||||
$statement->execute(['command_id' => $commandId]);
|
||||
$row = $statement->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
private function nullStr(string $value): ?string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
return $trimmed === '' ? null : $trimmed;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user