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:
2026-03-06 01:06:59 +01:00
parent 9df7a63244
commit 1b5e403c31
46 changed files with 6705 additions and 133 deletions

View File

@@ -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": {

View File

@@ -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 3050 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 23 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).

View File

@@ -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`

View File

@@ -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,

View File

@@ -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`,

View File

@@ -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

View File

@@ -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,

View File

@@ -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();

View File

@@ -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;

View File

@@ -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();

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();

View File

@@ -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

View File

@@ -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',
],
],
];

View File

@@ -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,

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">&larr; <?= $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'] ?? '-')) ?> &rarr; <?= $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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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">&larr; 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 !== ''): ?> &rarr; <?= $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>

View File

@@ -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]);
};

View File

@@ -272,7 +272,8 @@ final class Application
$oauthClient,
$apiClient,
new OrderImportRepository($this->db),
$statusMappingRepository
$statusMappingRepository,
new OrdersRepository($this->db)
);
$ordersSyncService = new AllegroOrdersSyncService(
$integrationRepository,

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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>
*/

View File

@@ -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 : [];
}
}

View File

@@ -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);

View File

@@ -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' => '',

View File

@@ -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

View File

@@ -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,
];

View 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');
}
}

View 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);
}
}

View 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');
}
}

View 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',
];
}
}

View 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');
}
}

View 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;
}
}

View 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));
}
}

View 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');
}
}

View 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;
}
}