From 5435209b0829c2bd6507d01c291f568d894e919a Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Mon, 30 Mar 2026 20:23:38 +0200 Subject: [PATCH] update --- .paul/ROADMAP.md | 1 + .paul/STATE.md | 25 +- .paul/phases/56-order-payments/56-01-PLAN.md | 248 +++++ .vscode/ftp-kr.sync.cache.json | 36 +- DOCS/ARCHITECTURE.md | 1 + DOCS/DB_SCHEMA.md | 20 + DOCS/TECH_CHANGELOG.md | 10 + ...ate_allegro_integration_settings_table.sql | 2 +- ...330_000073_create_order_payments_table.sql | 67 ++ public/assets/css/app.css | 40 + resources/lang/pl.php | 4 +- resources/scss/app.css | 910 +++++++++++++++++- resources/scss/app.scss | 41 + resources/scss/modules/_shipment-presets.scss | 1 + resources/views/orders/show.php | 296 +++++- resources/views/settings/cron.php | 36 - routes/web.php | 3 +- src/Modules/Cron/CronHandlerFactory.php | 3 +- src/Modules/Orders/OrdersController.php | 103 +- src/Modules/Orders/OrdersRepository.php | 88 ++ .../Settings/AllegroIntegrationController.php | 1 + src/Modules/Settings/AllegroOAuthClient.php | 1 + src/Modules/Settings/ShopproApiClient.php | 28 + .../Shipments/AllegroShipmentService.php | 16 +- .../Shipments/AllegroTrackingService.php | 117 ++- src/Modules/Shipments/DeliveryStatus.php | 11 +- 26 files changed, 1949 insertions(+), 160 deletions(-) create mode 100644 .paul/phases/56-order-payments/56-01-PLAN.md create mode 100644 database/migrations/20260330_000073_create_order_payments_table.sql diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md index 6e928f2..fad371f 100644 --- a/.paul/ROADMAP.md +++ b/.paul/ROADMAP.md @@ -16,6 +16,7 @@ Wersja mobilna aplikacji, modul po module. Cel: pelna uzywalnosc orderPRO na tel | 53 | Mobile Status Panel Toggle | 1/1 | Complete | | 54 | Order Detail Image Hover | 1/1 | Complete | | 55 | Desktop Collapsed Sidebar Fix | 1/1 | Complete | +| 56 | Order Payments | 0/1 | Planning | | TBD | Mobile Orders List | - | Not started | | TBD | Mobile Order Details | - | Not started | | TBD | Mobile Settings | - | Not started | diff --git a/.paul/STATE.md b/.paul/STATE.md index f9d6ca6..2dbde6f 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -5,34 +5,34 @@ See: .paul/PROJECT.md (updated 2026-03-28) **Core value:** Sprzedawca moze obslugiwac zamowienia ze wszystkich kanalow sprzedazy i nadawac przesylki bez przelaczania sie miedzy platformami. -**Current focus:** Milestone v3.0 Mobile Responsive — Phase 52 (Mobile Main Menu) planning +**Current focus:** Milestone v3.0 Mobile Responsive — Phase 56 (Order Payments) planning ## Current Position Milestone: v3.0 Mobile Responsive — In progress -Phase: 4 of N (55 - Desktop Collapsed Sidebar Fix) — Complete -Plan: 55-01 complete -Status: Loop complete — phase 55 done, ready for next PLAN -Last activity: 2026-03-29 — UNIFY closed for 55-01 +Phase: 5 of N (56 - Order Payments) — Planning +Plan: 56-01 created, awaiting approval +Status: PLAN created, ready for APPLY +Last activity: 2026-03-30 — Created .paul/phases/56-order-payments/56-01-PLAN.md Progress: - Milestone: [####░░░░░░] ~40% -- Phase 55: [##########] 100% +- Phase 56: [░░░░░░░░░░] 0% ## Loop Position Current loop state: ``` PLAN ──▶ APPLY ──▶ UNIFY - ✓ ✓ ✓ [Loop complete - ready for next PLAN] + ✓ ○ ○ [Plan created, awaiting approval] ``` ## Session Continuity -Last session: 2026-03-29 -Stopped at: Phase 55 complete -Next action: /paul:plan dla kolejnego modulu -Resume file: .paul/phases/55-desktop-collapsed-sidebar-fix/55-01-SUMMARY.md +Last session: 2026-03-30 +Stopped at: Plan 56-01 created +Next action: Review and approve plan, then run /paul:apply .paul/phases/56-order-payments/56-01-PLAN.md +Resume file: .paul/phases/56-order-payments/56-01-PLAN.md ## Accumulated Context @@ -41,9 +41,10 @@ Resume file: .paul/phases/55-desktop-collapsed-sidebar-fix/55-01-SUMMARY.md |------|----------|--------| | 2026-03-29 | Mobile menu jako slide-in overlay (nie horizontal scroll) | Pelna nawigacja na mobile bez kompromisow | | 2026-03-29 | Hamburger w topbarze, sidebar fixed z transform slide | Plynna animacja CSS, zero zaleznosci JS | +| 2026-03-30 | Push set_paid do shopPRO API po dodaniu platnosci w orderPRO | Synchronizacja statusu platnosci bez dodatkowego endpointu w shopPRO | ## Git State -Last commit: cbc2058 +Last commit: 70662af Branch: main Feature branches merged: none diff --git a/.paul/phases/56-order-payments/56-01-PLAN.md b/.paul/phases/56-order-payments/56-01-PLAN.md new file mode 100644 index 0000000..d1771af --- /dev/null +++ b/.paul/phases/56-order-payments/56-01-PLAN.md @@ -0,0 +1,248 @@ +--- +phase: 56-order-payments +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - database/migrations/20260330_000073_create_order_payments_table.sql + - src/Modules/Orders/OrdersController.php + - src/Modules/Orders/OrdersRepository.php + - src/Modules/Settings/ShopproApiClient.php + - resources/views/orders/show.php + - resources/lang/pl.php + - resources/scss/modules/_order-details.scss + - public/assets/css/app.css + - routes/web.php + - DOCS/DB_SCHEMA.md + - DOCS/ARCHITECTURE.md + - DOCS/TECH_CHANGELOG.md +autonomous: false +--- + + +## Goal +Uruchomienie funkcji dodawania platnosci do zamowienia z poziomu zakladki Platnosci w widoku zamowienia. Po dodaniu platnosci — automatyczny push statusu platnosci do shopPRO API (dla zamowien source=shoppro). + +## Purpose +Sprzedawca moze oznaczyc zamowienie jako oplacone bezposrednio z orderPRO i ta informacja jest synchronizowana do sklepu shopPRO. + +## Output +- Tabela `order_payments` w bazie (migracja) +- Formularz dodawania platnosci w zakladce Platnosci +- Endpoint POST `/orders/{id}/payment/add` +- Push `set_paid` do shopPRO API po dodaniu platnosci (source=shoppro) + + + +## Project Context +@.paul/PROJECT.md +@.paul/ROADMAP.md +@.paul/STATE.md + +## Source Files +@database/drafts/20260302_orders_schema_v1.sql (linie 102-121 — draft schemat order_payments) +@src/Modules/Orders/OrdersController.php +@src/Modules/Orders/OrdersRepository.php (loadOrderPayments, findDetails) +@src/Modules/Orders/OrderImportRepository.php (replacePayments) +@src/Modules/Settings/ShopproPaymentStatusSyncService.php (wzorzec update + push) +@src/Modules/Settings/ShopproApiClient.php (requestJsonPut) +@src/Modules/Settings/ShopproIntegrationsRepository.php +@resources/views/orders/show.php (zakladka Platnosci linie 559-648) +@routes/web.php (pattern: /orders/{id}/...) + + + + +## AC-1: Tabela order_payments istnieje w bazie +```gherkin +Given migracja 20260330_000073 zostala uruchomiona +When wykonam SHOW CREATE TABLE order_payments +Then tabela zawiera kolumny: id, order_id, source_payment_id, external_payment_id, payment_type_id, payment_date, amount, currency, comment, payload_json, created_at, updated_at +And istnieje FK na orders(id) z ON DELETE CASCADE +``` + +## AC-2: Formularz dodawania platnosci w zakladce Platnosci +```gherkin +Given otwieram zamowienie /orders/130 +When klikam zakladke Platnosci +Then widze przycisk "Dodaj platnosc" nad tabela platnosci +And po kliknieciu pojawia sie formularz inline z polami: kwota, typ platnosci (select), data platnosci, komentarz +And formularz zawiera przyciski Zapisz i Anuluj +``` + +## AC-3: Zapis platnosci przez AJAX +```gherkin +Given wypelniam formularz platnosci (kwota, typ, data) +When klikam Zapisz +Then platnosc jest zapisana w tabeli order_payments +And kolumny orders.payment_status i orders.total_paid sa zaktualizowane +And zakladka Platnosci odswieza sie z nowa platnoscia na liscie +And pojawia sie komunikat sukcesu +``` + +## AC-4: Push set_paid do shopPRO po dodaniu platnosci +```gherkin +Given zamowienie ma source=shoppro i polaczona integracje +When dodaje platnosc pokrywajaca pelna kwote zamowienia +Then orderPRO wywoluje PUT shopPRO API /api.php?endpoint=orders&action=set_paid&id={source_order_id} +And w activity_log zapisuje informacje o pushu +``` + +## AC-5: Walidacja formularza +```gherkin +Given otwieram formularz dodawania platnosci +When probuje zapisac bez kwoty lub z kwota <= 0 +Then formularz nie jest wysylany i pojawia sie komunikat bledu walidacji +``` + + + + + + + Task 1: Migracja order_payments + update orders columns + database/migrations/20260330_000073_create_order_payments_table.sql, DOCS/DB_SCHEMA.md + + Utworz migracje tworzaca tabele order_payments na podstawie draftu (database/drafts/20260302_orders_schema_v1.sql linie 102-121). + Uzyj CREATE TABLE IF NOT EXISTS — migracja idempotentna. + Schemat: id, order_id, source_payment_id, external_payment_id, payment_type_id, payment_date, amount, currency, comment, payload_json, created_at, updated_at. + Indexy: UNIQUE (order_id, source_payment_id), INDEX (order_id), INDEX (payment_date). + FK: order_payments_order_fk -> orders(id) ON DELETE CASCADE ON UPDATE CASCADE. + + Sprawdz tez czy kolumny payment_status, total_paid, external_payment_type_id istnieja w tabeli orders. Jezeli nie — dodaj ALTER TABLE (idempotentnie przez IF NOT EXISTS pattern z prepared statements). + + Zaktualizuj DOCS/DB_SCHEMA.md o nowa tabele. + + Uruchom migracje na serwerze: sprawdz SHOW CREATE TABLE order_payments + AC-1 satisfied: tabela order_payments istnieje z poprawnym schematem + + + + Task 2: Backend — endpoint dodawania platnosci + push do shopPRO + src/Modules/Orders/OrdersController.php, src/Modules/Orders/OrdersRepository.php, src/Modules/Settings/ShopproApiClient.php, routes/web.php, resources/lang/pl.php, DOCS/ARCHITECTURE.md + + 1. W routes/web.php dodaj route: + POST /orders/{id}/payment/add -> OrdersController->addPayment + + 2. W OrdersRepository dodaj metode addPayment(int $orderId, array $data): int + - INSERT INTO order_payments (order_id, payment_type_id, payment_date, amount, currency, comment) + - Przelicz total_paid: SELECT SUM(amount) FROM order_payments WHERE order_id + - UPDATE orders SET total_paid = sum, payment_status = (2 jesli sum >= total_with_tax, 1 jesli sum > 0, 0 jesli sum = 0) + - Zwroc nowy payment ID + + 3. W OrdersController dodaj metode addPayment(Request $request): Response + - Walidacja: orderId > 0, amount > 0, payment_type_id niepusty + - CSRF token + - Wywolaj OrdersRepository::addPayment() + - Jezeli zamowienie ma source=shoppro i payment_status stalo sie 2 (oplacone): + - Pobierz dane integracji (integration_id z zamowienia) + - Wywolaj ShopproApiClient::pushPaymentPaid() do set_paid + - Zaloguj w activity_log + - Return JSON response {ok, payment, payment_status, total_paid} + + 4. W ShopproApiClient dodaj metode pushPaymentPaid(string $baseUrl, string $apiKey, int $timeout, string $sourceOrderId): array + - PUT {baseUrl}/api.php?endpoint=orders&action=set_paid&id={sourceOrderId} + - Body: {"send_email": 0} + - Uzyj istniejacego requestJsonPut() + + 5. Zaktualizuj resources/lang/pl.php o klucze bledow/komunikatow platnosci. + + 6. Zaktualizuj DOCS/ARCHITECTURE.md. + + + curl -X POST /orders/130/payment/add z danymi platnosci — odpowiedz 200 z JSON + + AC-3, AC-4, AC-5 satisfied: platnosc zapisywana, push do shopPRO, walidacja + + + + Task 3: Frontend — formularz platnosci w zakladce + AJAX + resources/views/orders/show.php, resources/scss/modules/_order-details.scss, public/assets/css/app.css + + 1. W show.php, w sekcji data-order-tab-panel="payments" (linia ~606): + - Dodaj przycisk "Dodaj platnosc" (klasa btn btn--sm btn--primary) nad tabela/komunikatem "Brak platnosci" + - Po kliknieciu — pokaz formularz inline (ukryty domyslnie): + - Kwota (input number, step=0.01, required) — domyslna: total_with_tax - total_paid + - Typ platnosci (select: ONLINE, TRANSFER, CASH_ON_DELIVERY) + - Data platnosci (input date, domyslna: dzisiaj) + - Komentarz (input text, opcjonalny) + - Przyciski: Zapisz (submit AJAX), Anuluj (ukryj formularz) + + 2. JavaScript na dole widoku (pattern jak inne formularze AJAX w tym pliku): + - POST /orders/{orderId}/payment/add + - Wysylaj JSON: {amount, payment_type_id, payment_date, comment, _token} + - On success: window.location.reload() dla odswiezenia karty + - On error: pokaz blad przez OrderProAlerts.error() + + 3. Style w _order-details.scss: + - .payment-add-form — kompaktowy formularz inline + - .payment-add-form__row — flex row z gap + - Responsywnosc (na mobile kolumny zawijaja sie) + + 4. Zbuduj SCSS: npm run build (lub sass compile) + + + Otworz /orders/130, kliknij Platnosci, kliknij "Dodaj platnosc", wypelnij formularz, zapisz — platnosc pojawia sie na liscie + + AC-2, AC-3 satisfied: formularz widoczny, zapis dziala, lista odswieza sie + + + + Formularz dodawania platnosci + push do shopPRO + + 1. Otworz: https://orderpro.projectpro.pl/orders/130 + 2. Kliknij zakladke "Platnosci" + 3. Kliknij "Dodaj platnosc" + 4. Wypelnij kwote (np. 100.00), wybierz typ "Platnosc online", data dzisiejsza + 5. Kliknij "Zapisz" + 6. Sprawdz: platnosc pojawia sie w tabeli, status platnosci zaktualizowal sie + 7. Sprawdz shopPRO: czy zamowienie zostalo oznaczone jako oplacone + 8. Sprobuj dodac platnosc z kwota 0 — powinien byc blad walidacji + + Type "approved" to continue, or describe issues to fix + + + + + + +## DO NOT CHANGE +- src/Modules/Settings/ShopproPaymentStatusSyncService.php (istniejacy cron sync — nie modyfikowac) +- src/Modules/Orders/OrderImportRepository.php (import platnosci z API — nie modyfikowac) +- database/migrations/ istniejace migracje (nie modyfikowac) +- src/Modules/Cron/* (nie modyfikowac handlerow crona) + +## SCOPE LIMITS +- Tylko dodawanie platnosci — nie edycja ani usuwanie +- Nie implementujemy zwrotow (refunds) +- Push do shopPRO tylko przez istniejace API set_paid — nie dodajemy nowego endpointu w shopPRO +- Nie modyfikujemy synchronizacji platnosci Allegro +- Nie dodajemy nowych cron jobow + + + + +Before declaring plan complete: +- [ ] Migracja uruchomiona — tabela order_payments istnieje +- [ ] POST /orders/{id}/payment/add zwraca 200 z poprawnymi danymi +- [ ] Formularz w UI wyswietla sie poprawnie +- [ ] Platnosc zapisana w order_payments +- [ ] orders.payment_status i total_paid zaktualizowane +- [ ] Push set_paid do shopPRO (source=shoppro) dziala +- [ ] Walidacja formularza — kwota > 0, typ niepusty +- [ ] DOCS zaktualizowane +- [ ] SCSS zbudowany do CSS + + + +- Wszystkie taski ukonczone +- Wszystkie AC spelnione +- Platnosc dodana z UI pojawia sie w zakladce Platnosci +- shopPRO otrzymuje informacje o oplaceniu +- Brak bledow PHP/JS w konsoli + + + +After completion, create `.paul/phases/56-order-payments/56-01-SUMMARY.md` + diff --git a/.vscode/ftp-kr.sync.cache.json b/.vscode/ftp-kr.sync.cache.json index aeea95f..79a867b 100644 --- a/.vscode/ftp-kr.sync.cache.json +++ b/.vscode/ftp-kr.sync.cache.json @@ -1824,8 +1824,8 @@ "css": { "app.css": { "type": "-", - "size": 45416, - "lmtime": 1774702916830, + "size": 57250, + "lmtime": 1774820920578, "modified": false }, "app.css.map": { @@ -1897,8 +1897,8 @@ "lang": { "pl.php": { "type": "-", - "size": 62881, - "lmtime": 1773788074010, + "size": 62845, + "lmtime": 1774861982010, "modified": false } }, @@ -1933,8 +1933,8 @@ }, "app.scss": { "type": "-", - "size": 43784, - "lmtime": 1774701658193, + "size": 45727, + "lmtime": 1774820913052, "modified": false }, "login.css": { @@ -2013,17 +2013,17 @@ } }, "components": { - "table-list.php": { - "type": "-", - "size": 21805, - "lmtime": 1771925480312, - "modified": false - }, "order-status-panel.php": { "type": "-", - "size": 1743, - "lmtime": 1772497361423, + "size": 2682, + "lmtime": 1774819455980, "modified": false + }, + "table-list.php": { + "type": "-", + "size": 21914, + "lmtime": 1771925480312, + "modified": true } }, "dashboard": { @@ -2037,8 +2037,8 @@ "layouts": { "app.php": { "type": "-", - "size": 9591, - "lmtime": 1774566330881, + "size": 11645, + "lmtime": 1774818596878, "modified": false }, "auth.php": { @@ -2091,8 +2091,8 @@ }, "show.php": { "type": "-", - "size": 40747, - "lmtime": 1774474658482, + "size": 41726, + "lmtime": 1774820344044, "modified": false } }, diff --git a/DOCS/ARCHITECTURE.md b/DOCS/ARCHITECTURE.md index 9ff7b91..f7f9117 100644 --- a/DOCS/ARCHITECTURE.md +++ b/DOCS/ARCHITECTURE.md @@ -41,6 +41,7 @@ - `POST /orders/{id}/status` - `POST /orders/{id}/send-email` (wysylka e-mail z zamowienia, AJAX) - `POST /orders/{id}/email-preview` (podglad szablonu z rozwiazanymi zmiennymi, AJAX) +- `POST /orders/{id}/payment/add` (dodanie platnosci recznej, AJAX + push set_paid do shopPRO) - `GET /accounting` (lista paragonow z filtrami i paginacja) - `GET /accounting/export` (eksport XLSX z aktywnymi filtrami) - `GET /users` (redirect do `/settings/users`) diff --git a/DOCS/DB_SCHEMA.md b/DOCS/DB_SCHEMA.md index b69b2be..78c7c7c 100644 --- a/DOCS/DB_SCHEMA.md +++ b/DOCS/DB_SCHEMA.md @@ -223,6 +223,26 @@ Migracje z prefiksem `ensure_` to migracje kompensujące — zostały dodane - `allegro_order_status_mappings_code_unique` (UNIQUE: `allegro_status_code`), - `allegro_order_status_mappings_orderpro_code_idx` (`orderpro_status_code`). +### `order_payments` +- Platnosci zamowien (z importu API lub reczne). +- Kolumny: + - `id` (PK, bigint unsigned, AI), + - `order_id` (bigint unsigned, FK -> `orders.id`, CASCADE), + - `source_payment_id` (varchar 64, nullable), + - `external_payment_id` (varchar 64, nullable), + - `payment_type_id` (varchar 64, NOT NULL) — typ: ONLINE, TRANSFER, CASH_ON_DELIVERY, + - `payment_date` (datetime, nullable), + - `amount` (decimal 12,2, nullable), + - `currency` (char 3, nullable), + - `comment` (varchar 255, nullable), + - `payload_json` (json, nullable), + - `created_at`, `updated_at`. +- Indeksy: + - `order_payments_order_source_payment_unique` (UNIQUE: `order_id`, `source_payment_id`), + - `order_payments_order_idx` (`order_id`), + - `order_payments_date_idx` (`payment_date`). +- Migracja: `20260330_000073_create_order_payments_table.sql` + ### `order_activity_log` - Uniwersalny log aktywnosci zamowienia (zmiany statusow, platnosci, przesylki, faktury, wiadomosci itp.). - Kolumny: diff --git a/DOCS/TECH_CHANGELOG.md b/DOCS/TECH_CHANGELOG.md index aa7d8c0..a79fd06 100644 --- a/DOCS/TECH_CHANGELOG.md +++ b/DOCS/TECH_CHANGELOG.md @@ -1,5 +1,15 @@ # Tech Changelog +## 2026-03-30 (Phase 56 - Order Payments, Plan 01) +- Migracja `20260330_000073_create_order_payments_table.sql`: tabela `order_payments` (id, order_id, source_payment_id, external_payment_id, payment_type_id, payment_date, amount, currency, comment, payload_json) + idempotentne dodanie kolumn `total_with_tax`, `total_paid`, `external_payment_type_id` do `orders`. +- `OrdersRepository::addPayment()`: INSERT do `order_payments`, przeliczenie `total_paid` i `payment_status` na `orders`. +- `OrdersRepository::findOrderSourceInfo()`: pobiera `source`, `integration_id`, `source_order_id` dla push do shopPRO. +- `OrdersController::addPayment()`: POST `/orders/{id}/payment/add` — walidacja, zapis platnosci, activity log, push do shopPRO. +- `OrdersController::pushPaymentToShoppro()`: po dodaniu platnosci pokrywajacej calosc — PUT `set_paid` do shopPRO API. +- `ShopproApiClient::setOrderPaid()`: PUT `/api.php?endpoint=orders&action=set_paid&id={sourceOrderId}`. +- `resources/views/orders/show.php`: przycisk "Dodaj platnosc", formularz inline (kwota, typ, data, komentarz), AJAX submit z reload. +- Style: `.payment-add-form` w `resources/scss/app.scss`. + ## 2026-03-28 (Phase 51 - Email HTML Layout, Plan 01) - Migracja `20260328_000001_add_html_layout_to_email_mailboxes.sql`: kolumny `header_html` TEXT NULL i `footer_html` TEXT NULL w `email_mailboxes`. - `EmailMailboxRepository::save()`: zapis `header_html`/`footer_html` w INSERT i UPDATE. diff --git a/database/migrations/20260304_000023_create_allegro_integration_settings_table.sql b/database/migrations/20260304_000023_create_allegro_integration_settings_table.sql index 4646b56..af03c4e 100644 --- a/database/migrations/20260304_000023_create_allegro_integration_settings_table.sql +++ b/database/migrations/20260304_000023_create_allegro_integration_settings_table.sql @@ -1,5 +1,5 @@ CREATE TABLE IF NOT EXISTS allegro_integration_settings ( - id TINYINT UNSIGNED NOT NULL PRIMARY KEY, + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, environment VARCHAR(16) NOT NULL DEFAULT 'sandbox', client_id VARCHAR(128) NULL, client_secret_encrypted TEXT NULL, diff --git a/database/migrations/20260330_000073_create_order_payments_table.sql b/database/migrations/20260330_000073_create_order_payments_table.sql new file mode 100644 index 0000000..f20688c --- /dev/null +++ b/database/migrations/20260330_000073_create_order_payments_table.sql @@ -0,0 +1,67 @@ +-- ============================================================= +-- Migracja: Tabela order_payments +-- Cel: Przechowywanie platnosci zamowien (recznych i z importu) +-- Zrodlo schematu: database/drafts/20260302_orders_schema_v1.sql +-- Idempotentna: CREATE TABLE IF NOT EXISTS +-- ============================================================= + +CREATE TABLE IF NOT EXISTS order_payments ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + order_id BIGINT UNSIGNED NOT NULL, + source_payment_id VARCHAR(64) NULL, + external_payment_id VARCHAR(64) NULL, + payment_type_id VARCHAR(64) NOT NULL, + payment_date DATETIME NULL, + amount DECIMAL(12,2) NULL, + currency CHAR(3) NULL, + comment VARCHAR(255) 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, + UNIQUE KEY order_payments_order_source_payment_unique (order_id, source_payment_id), + KEY order_payments_order_idx (order_id), + KEY order_payments_date_idx (payment_date), + CONSTRAINT order_payments_order_fk + FOREIGN KEY (order_id) REFERENCES orders(id) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Kolumny payment w orders — idempotentne dodanie (mogly byc juz zaladowane z deploy scriptu) +SET @sql := ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `orders` ADD COLUMN `total_with_tax` DECIMAL(12,2) NULL AFTER `total_net`', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'orders' + AND column_name = 'total_with_tax' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `orders` ADD COLUMN `total_paid` DECIMAL(12,2) NULL AFTER `total_with_tax`', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'orders' + AND column_name = 'total_paid' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `orders` ADD COLUMN `external_payment_type_id` VARCHAR(128) NULL AFTER `payment_status`', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'orders' + AND column_name = 'external_payment_type_id' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/public/assets/css/app.css b/public/assets/css/app.css index 839746e..677dd06 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -545,6 +545,7 @@ flex-wrap: wrap; gap: 8px; align-items: center; + margin-top: 16px; margin-bottom: 16px; } @@ -2355,6 +2356,45 @@ details[open] > .order-statuses-side__title .order-statuses-side__arrow { color: #0f172a; } +.payment-add-form { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 12px; + max-width: 700px; +} + +.payment-add-form__row { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.payment-add-form__field { + display: flex; + flex-direction: column; + gap: 3px; + flex: 1 1 140px; + min-width: 120px; +} +.payment-add-form__field label { + font-size: 11px; + color: #64748b; + font-weight: 500; +} +.payment-add-form__field input, .payment-add-form__field select { + font-size: 12px; + padding: 4px 8px; + border: 1px solid #cbd5e1; + border-radius: 4px; + height: 30px; +} + +.payment-add-form__actions { + display: flex; + gap: 8px; +} + .order-kv dt { color: #64748b; } diff --git a/resources/lang/pl.php b/resources/lang/pl.php index e754fb3..6e38a27 100644 --- a/resources/lang/pl.php +++ b/resources/lang/pl.php @@ -24,7 +24,7 @@ return [ 'orders' => 'Zamowienia', 'orders_list' => 'Lista zamowien', 'marketplace' => 'Marketplace', - 'cron' => 'Cron', + 'cron' => 'Harmonogram', 'dashboard' => 'Dashboard', 'settings' => 'Ustawienia', 'statuses' => 'Statusy', @@ -1038,7 +1038,7 @@ return [ ], ], 'cron' => [ - 'title' => 'Cron', + 'title' => 'Harmonogram', 'run_on_web_title' => 'Uruchamianie crona podczas nawigacji', 'run_on_web_description' => 'Po wlaczeniu worker cron uruchamia sie automatycznie podczas poruszania po panelu.', 'run_on_web_label' => 'Wlacz uruchamianie crona podczas requestow HTTP', diff --git a/resources/scss/app.css b/resources/scss/app.css index 9bceac3..677dd06 100644 --- a/resources/scss/app.css +++ b/resources/scss/app.css @@ -2,6 +2,8 @@ :root { --c-primary: #6690f4; --c-primary-dark: #3164db; + --c-action-primary: #0f766e; + --c-action-primary-dark: #0b5f59; --c-bg: #f4f6f9; --c-surface: #ffffff; --c-text: #4e5e6a; @@ -10,6 +12,7 @@ --c-border: #b0bec5; --c-danger: #cc0000; --focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15); + --focus-ring-action: 0 0 0 3px rgba(15, 118, 110, 0.18); --shadow-card: 0 1px 4px rgba(0, 0, 0, 0.06); } @@ -30,11 +33,11 @@ .btn--primary { color: #ffffff; - background: var(--c-primary); + background: var(--c-action-primary); } .btn--primary:hover { - background: var(--c-primary-dark); + background: var(--c-action-primary-dark); } .btn--secondary { @@ -69,22 +72,28 @@ width: 100%; } +.btn--disabled { + opacity: 0.3; + cursor: not-allowed; + pointer-events: none; +} + .btn:active { transform: translateY(1px); } .btn:focus-visible { outline: none; - box-shadow: var(--focus-ring); - border-color: var(--c-primary); + box-shadow: var(--focus-ring-action); + border-color: var(--c-action-primary); } .form-control { width: 100%; - min-height: 34px; + min-height: 30px; border: 1px solid var(--c-border); - border-radius: 8px; - padding: 5px 10px; + border-radius: 6px; + padding: 4px 8px; font: inherit; color: var(--c-text-strong); background: #ffffff; @@ -239,6 +248,518 @@ background: #edf2ff; } +.receipt-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 2px solid var(--c-text-strong); +} +.receipt-header__seller { + flex: 1; +} +.receipt-header__seller strong { + font-size: 14px; + display: block; + margin-bottom: 4px; +} +.receipt-header__title { + text-align: right; +} +.receipt-header__title h1 { + font-size: 18px; + font-weight: 700; + margin-bottom: 4px; +} + +.receipt-print { + max-width: 700px; + margin: 0 auto; +} +@media print { + .receipt-print { + max-width: 100%; + } +} + +.email-send-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; +} + +.email-send-modal { + background: var(--c-card-bg, #fff); + border-radius: 8px; + width: 580px; + max-width: 95vw; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); +} +.email-send-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--c-border, #e0e0e0); +} +.email-send-modal__header h3 { + margin: 0; + font-size: 15px; +} +.email-send-modal__close { + background: none; + border: none; + font-size: 20px; + cursor: pointer; + color: var(--c-text-muted, #888); + padding: 0 4px; +} +.email-send-modal__close:hover { + color: var(--c-text, #333); +} +.email-send-modal__body { + padding: 16px; + overflow-y: auto; + flex: 1; +} +.email-send-modal__field { + margin-bottom: 10px; +} +.email-send-modal__field label { + display: block; + font-size: 12px; + font-weight: 600; + margin-bottom: 4px; + color: var(--c-text-muted, #666); +} +.email-send-modal__field .input { + width: 100%; +} +.email-send-modal__actions-top { + margin-bottom: 10px; +} +.email-send-modal__footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid var(--c-border, #e0e0e0); +} + +.email-send-preview { + border: 1px solid var(--c-border, #e0e0e0); + border-radius: 4px; + padding: 12px; + max-height: 280px; + overflow-y: auto; + background: var(--c-bg, #fafafa); +} +.email-send-preview__subject { + font-weight: 600; + font-size: 13px; + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid var(--c-border, #e0e0e0); +} +.email-send-preview__body { + font-size: 13px; + line-height: 1.5; +} +.email-send-preview__body p { + margin: 0 0 8px; +} +.email-send-preview__attachments { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--c-border, #e0e0e0); + font-size: 12px; + color: var(--c-text-muted, #666); +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.automation-row { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 12px; + background: var(--c-surface, #f8f9fa); + border: 1px solid var(--c-border, #dee2e6); + border-radius: 6px; +} +.automation-row__fields { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; +} +.automation-row__type { + max-width: 280px; +} +.automation-row__config { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.automation-row__config .form-control { + min-width: 200px; + max-width: 300px; +} +.automation-row__remove { + flex-shrink: 0; + margin-top: 2px; + line-height: 1; + font-size: 16px; + padding: 2px 8px; +} + +.checkbox-group { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 4px; + font-size: 13px; + cursor: pointer; + white-space: nowrap; +} +.checkbox-label input[type=checkbox] { + margin: 0; +} + +.automation-actions-cell { + white-space: nowrap; +} + +.automation-inline-form { + display: inline; +} + +.automation-history-filters { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 8px; + align-items: end; +} +.automation-history-filters .form-field { + margin: 0; +} +.automation-history-filters .field-label { + font-size: 12px; + margin-bottom: 4px; +} +.automation-history-filters .form-control { + min-height: 34px; +} +.automation-history-filters__actions { + display: flex; + gap: 6px; + align-items: center; + justify-content: flex-start; + padding-bottom: 1px; +} + +.print-status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.4; +} +.print-status-badge--pending { + background-color: #fff3cd; + color: #856404; +} +.print-status-badge--completed { + background-color: #d4edda; + color: #155724; +} +.print-status-badge--failed { + background-color: #f8d7da; + color: #721c24; +} + +.print-queue-filters { + display: flex; + gap: 4px; +} + +.print-queue-table td, .print-queue-table th { + padding: 6px 8px; + font-size: 0.85rem; +} + +.print-queue-actions { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.print-queue-delete-form { + margin: 0; +} + +.btn--outline-primary { + background: transparent; + border: 1px solid var(--c-action-primary); + color: var(--c-action-primary); + cursor: pointer; + border-radius: 3px; + font-size: 0.75rem; + padding: 3px 8px; + transition: background-color 0.15s, color 0.15s; +} +.btn--outline-primary:hover { + background-color: var(--c-action-primary); + color: #fff; +} +.btn--outline-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} +.btn--outline-primary.is-success { + border-color: #28a745; + color: #28a745; +} + +.shipment-presets { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-top: 16px; + margin-bottom: 16px; +} + +.shipment-presets__btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 14px; + border: none; + border-radius: 6px; + background: var(--preset-color, #3b82f6); + color: #fff; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: opacity 0.15s; + line-height: 1.4; +} +.shipment-presets__btn:hover { + opacity: 0.85; +} + +.shipment-presets__add { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 14px; + border: 1px dashed #ccc; + border-radius: 6px; + background: transparent; + color: #666; + font-size: 13px; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; + line-height: 1.4; +} +.shipment-presets__add:hover { + border-color: #999; + color: #444; +} + +.preset-modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.preset-modal__content { + background: #fff; + border-radius: 8px; + padding: 24px; + min-width: 360px; + max-width: 420px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); +} +.preset-modal__content h3 { + margin: 0 0 4px; + font-size: 16px; +} + +.preset-modal__colors { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.preset-modal__color-swatch { + width: 28px; + height: 28px; + border-radius: 50%; + cursor: pointer; + border: 2px solid transparent; + transition: border-color 0.15s; +} +.preset-modal__color-swatch:hover { + border-color: #aaa; +} +.preset-modal__color-swatch.is-selected { + border-color: #333; +} + +.shipment-presets__btn-wrap { + position: relative; + display: inline-flex; +} +.shipment-presets__btn-wrap:hover .shipment-presets__edit-icon { + opacity: 1; +} + +.shipment-presets__edit-icon { + position: absolute; + top: -6px; + right: -6px; + width: 18px; + height: 18px; + border-radius: 50%; + background: #fff; + border: 1px solid #ddd; + font-size: 10px; + line-height: 16px; + text-align: center; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s; + padding: 0; + color: #666; + z-index: 2; +} +.shipment-presets__edit-icon:hover { + background: #f3f4f6; + border-color: #999; +} + +.shipment-presets__dropdown { + position: absolute; + top: 100%; + left: 0; + margin-top: 4px; + background: #fff; + border: 1px solid #ddd; + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + z-index: 100; + min-width: 200px; + padding: 4px 0; +} + +.shipment-presets__dropdown-item { + padding: 6px 14px; + font-size: 13px; + cursor: pointer; + white-space: nowrap; +} +.shipment-presets__dropdown-item:hover { + background: #f3f4f6; +} +.shipment-presets__dropdown-item.is-danger { + color: #ef4444; +} +.shipment-presets__dropdown-item.is-danger:hover { + background: #fef2f2; +} + +.delivery-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-size: 0.8em; + font-weight: 500; + white-space: nowrap; +} +.delivery-badge--unknown { + background: #f5f5f5; + color: #999; +} +.delivery-badge--created { + background: #e3f2fd; + color: #1565c0; +} +.delivery-badge--confirmed { + background: #bbdefb; + color: #0d47a1; +} +.delivery-badge--in_transit { + background: #fff3e0; + color: #e65100; +} +.delivery-badge--out_for_delivery { + background: #ffe0b2; + color: #bf360c; +} +.delivery-badge--ready_for_pickup { + background: #f3e5f5; + color: #6a1b9a; +} +.delivery-badge--delivered { + background: #e8f5e9; + color: #2e7d32; +} +.delivery-badge--returned { + background: #ffebee; + color: #c62828; +} +.delivery-badge--cancelled { + background: #e0e0e0; + color: #616161; +} +.delivery-badge--problem { + background: #fff8e1; + color: #f57f17; +} + +.tracking-link { + margin-left: 4px; + text-decoration: none; + font-size: 0.85em; +} + +.dsm-row--custom { + background: rgba(59, 130, 246, 0.06); +} + +.dsm-raw-status { + font-size: 0.82rem; + background: var(--surface-alt, #f1f5f9); + padding: 2px 6px; + border-radius: 3px; + white-space: nowrap; +} + * { box-sizing: border-box; } @@ -281,6 +802,33 @@ a { .sidebar.is-collapsed { width: 52px; min-width: 52px; + padding: 18px 0; +} +.sidebar.is-collapsed .sidebar__brand-text { + display: none; +} +.sidebar.is-collapsed .sidebar__brand { + justify-content: center; + margin: 4px 0 16px; +} +.sidebar.is-collapsed .sidebar__label { + display: none; +} +.sidebar.is-collapsed .sidebar__toggle-arrow { + display: none; +} +.sidebar.is-collapsed .sidebar__link, +.sidebar.is-collapsed .sidebar__group-toggle { + justify-content: center; + padding: 9px; + border-radius: 8px; + margin: 0 6px; +} +.sidebar.is-collapsed .sidebar__group-links { + display: none; +} +.sidebar.is-collapsed .sidebar__icon { + margin: 0; } .sidebar__brand { @@ -342,8 +890,12 @@ a { } .sidebar__link { + display: flex; + align-items: center; + gap: 9px; + white-space: nowrap; border-radius: 8px; - padding: 10px 12px; + padding: 9px 10px; text-decoration: none; color: #cbd5e1; font-weight: 600; @@ -674,7 +1226,7 @@ h4.section-title::before { .filters-actions { display: flex; - align-items: end; + align-items: center; gap: 8px; } @@ -692,18 +1244,21 @@ h4.section-title::before { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; + align-items: start; } .form-grid-3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; + align-items: start; } .form-grid-4 { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; + align-items: start; } .form-actions { @@ -1017,6 +1572,7 @@ h4.section-title::before { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + align-items: end; } .table-col-toggle-wrapper { @@ -1286,6 +1842,59 @@ h4.section-title::before { align-items: center; gap: 5px; flex-wrap: wrap; + cursor: pointer; +} +.orders-status-wrap .order-tag { + cursor: pointer; +} + +.orders-status-dropdown { + position: fixed; + z-index: 9999; + min-width: 180px; + max-height: 280px; + overflow-y: auto; + background: #fff; + border: 1px solid #d8e1ef; + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + padding: 4px 0; +} +.orders-status-dropdown__group-header { + padding: 6px 12px 2px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; +} +.orders-status-dropdown__group-header:not(:first-child) { + border-top: 1px solid #f1f5f9; + margin-top: 2px; + padding-top: 8px; +} +.orders-status-dropdown__item { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 12px; + font-size: 13px; + color: #334155; + cursor: pointer; + white-space: nowrap; +} +.orders-status-dropdown__item:hover { + background: #f1f5f9; +} +.orders-status-dropdown__item.is-current { + font-weight: 700; + background: #f8fafc; +} +.orders-status-dropdown__color-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; } .order-tag { @@ -1525,6 +2134,22 @@ h4.section-title::before { font-weight: 700; color: #0f172a; margin-bottom: 8px; + display: flex; + align-items: center; + justify-content: space-between; + list-style: none; +} +.order-statuses-side__title::-webkit-details-marker { + display: none; +} +.order-statuses-side__arrow { + display: none; + flex-shrink: 0; + opacity: 0.5; + transition: transform 0.2s ease; +} +details[open] > .order-statuses-side__title .order-statuses-side__arrow { + transform: rotate(180deg); } .order-status-group { @@ -1731,6 +2356,45 @@ h4.section-title::before { color: #0f172a; } +.payment-add-form { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 12px; + max-width: 700px; +} + +.payment-add-form__row { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.payment-add-form__field { + display: flex; + flex-direction: column; + gap: 3px; + flex: 1 1 140px; + min-width: 120px; +} +.payment-add-form__field label { + font-size: 11px; + color: #64748b; + font-weight: 500; +} +.payment-add-form__field input, .payment-add-form__field select { + font-size: 12px; + padding: 4px 8px; + border: 1px solid #cbd5e1; + border-radius: 4px; + height: 30px; +} + +.payment-add-form__actions { + display: flex; + gap: 8px; +} + .order-kv dt { color: #64748b; } @@ -1779,6 +2443,15 @@ h4.section-title::before { display: block; } +.manual-tracking-form { + display: flex; + gap: 8px; + align-items: center; +} +.manual-tracking-form .form-control { + max-width: 220px; +} + .order-empty-placeholder { border: 1px dashed #cbd5e1; border-radius: 8px; @@ -2355,32 +3028,74 @@ h4.section-title::before { color: #334155; } +.topbar__hamburger { + display: none; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + background: transparent; + border: none; + color: var(--c-text-strong); + cursor: pointer; + border-radius: 6px; + flex-shrink: 0; +} +.topbar__hamburger:hover { + background: var(--c-bg-subtle, #f1f5f9); +} + +.sidebar-backdrop { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 999; + opacity: 0; + transition: opacity 0.25s ease; +} +.sidebar-backdrop.is-visible { + display: block; + opacity: 1; +} + +body.no-scroll { + overflow: hidden; +} + @media (max-width: 768px) { - .app-shell { - flex-direction: column; + .topbar__hamburger { + display: flex; } .sidebar { - width: 100% !important; - min-width: 0 !important; - border-right: 0; - border-bottom: 1px solid #243041; - padding: 14px; - overflow-x: auto; + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: 280px; + min-width: 280px; + z-index: 1000; + transform: translateX(-100%); + transition: transform 0.25s ease; + border-right: 1px solid #243041; + overflow-y: auto; + } + .sidebar.is-mobile-open { + transform: translateX(0); } .sidebar__brand { - margin: 0 0 10px; - font-size: 22px; + margin: 4px 4px 12px; } .sidebar__collapse-btn { - display: none; + display: flex; + } + .sidebar__collapse-icon { + transform: rotate(180deg); } .sidebar__nav { - display: flex; - gap: 8px; - overflow-x: auto; - } - .sidebar__link { - white-space: nowrap; + display: grid; + gap: 4px; } .topbar { padding: 0 14px; @@ -2410,6 +3125,12 @@ h4.section-title::before { position: static; top: auto; } + .order-statuses-side__title { + cursor: pointer; + } + .order-statuses-side__arrow { + display: block; + } .order-details-actions { justify-content: flex-start; } @@ -2462,4 +3183,141 @@ h4.section-title::before { .modal--image-preview { width: min(92vw, 100%); } + .email-tpl-editor-wrap { + flex-direction: column; + } + .email-tpl-var-panel { + min-width: 200px; + } + .modal-box { + width: 95vw; + max-height: 90vh; + } +} +.email-tpl-editor-wrap { + display: flex; + flex-direction: column; + border: 1px solid var(--c-border); + border-radius: 6px; + overflow: visible; +} + +.email-tpl-toolbar { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + background: var(--c-bg-subtle, #f8f9fa); + border-bottom: 1px solid var(--c-border); +} + +.email-tpl-var-dropdown { + position: relative; +} + +.email-tpl-var-panel { + position: absolute; + top: 100%; + left: 0; + z-index: 300; + min-width: 260px; + max-height: 320px; + overflow-y: auto; + background: var(--c-bg); + border: 1px solid var(--c-border); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + padding: 6px; + margin-top: 4px; +} + +.email-var-group:not(:first-child) { + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid var(--c-border); +} +.email-var-group__label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + color: var(--c-text-muted); + padding: 2px 4px; + letter-spacing: 0.03em; +} + +.email-var-item { + display: block; + width: 100%; + text-align: left; + padding: 3px 6px; + margin: 1px 0; + border: none; + background: none; + font-size: 12px; + font-family: "Roboto Mono", monospace; + color: var(--c-text); + border-radius: 3px; + cursor: pointer; +} +.email-var-item:hover { + background: var(--c-primary); + color: #fff; +} + +#js-quill-editor { + min-height: 200px; +} +#js-quill-editor .ql-editor { + min-height: 200px; + font-size: 13px; +} + +.modal-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); +} + +.modal-box { + width: min(680px, 90vw); + max-height: 80vh; + background: var(--c-bg); + border-radius: 8px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2); + display: flex; + flex-direction: column; + overflow: hidden; +} +.modal-box__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + border-bottom: 1px solid var(--c-border); +} +.modal-box__title { + margin: 0; + font-size: 15px; + font-weight: 600; +} +.modal-box__close { + background: none; + border: none; + font-size: 22px; + line-height: 1; + cursor: pointer; + color: var(--c-text-muted); + padding: 0 4px; +} +.modal-box__close:hover { + color: var(--c-text); +} +.modal-box__body { + padding: 12px 16px; + overflow-y: auto; + flex: 1; } diff --git a/resources/scss/app.scss b/resources/scss/app.scss index ab50a3f..6473d35 100644 --- a/resources/scss/app.scss +++ b/resources/scss/app.scss @@ -1645,6 +1645,47 @@ details[open] > .order-statuses-side__title .order-statuses-side__arrow { color: #0f172a; } +.payment-add-form { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 12px; + max-width: 700px; +} + +.payment-add-form__row { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.payment-add-form__field { + display: flex; + flex-direction: column; + gap: 3px; + flex: 1 1 140px; + min-width: 120px; + + label { + font-size: 11px; + color: #64748b; + font-weight: 500; + } + + input, select { + font-size: 12px; + padding: 4px 8px; + border: 1px solid #cbd5e1; + border-radius: 4px; + height: 30px; + } +} + +.payment-add-form__actions { + display: flex; + gap: 8px; +} + .order-kv dt { color: #64748b; } diff --git a/resources/scss/modules/_shipment-presets.scss b/resources/scss/modules/_shipment-presets.scss index a4bdbc7..c17993e 100644 --- a/resources/scss/modules/_shipment-presets.scss +++ b/resources/scss/modules/_shipment-presets.scss @@ -3,6 +3,7 @@ flex-wrap: wrap; gap: 8px; align-items: center; + margin-top: 16px; margin-bottom: 16px; } diff --git a/resources/views/orders/show.php b/resources/views/orders/show.php index 35459cd..915e714 100644 --- a/resources/views/orders/show.php +++ b/resources/views/orders/show.php @@ -444,12 +444,20 @@ foreach ($addressesList as $address) { Dodana recznie - + + + + -
+
@@ -462,13 +470,13 @@ foreach ($addressesList as $address) { ?> - + 🔗 - - + @@ -595,8 +603,42 @@ foreach ($addressesList as $address) { +
+ +
+ + + -

Brak zarejestrowanych płatności.

+

Brak zarejestrowanych płatności.

@@ -739,41 +781,41 @@ foreach ($addressesList as $address) { try { savedTab = localStorage.getItem(storageKey); } catch (e) {} setActiveTab(forceTab || savedTab || 'details'); - // Print label button handler - document.querySelectorAll('.btn-print-label').forEach(function (btn) { - btn.addEventListener('click', function () { - var packageId = btn.getAttribute('data-package-id'); - if (!packageId) return; - btn.disabled = true; - var originalText = btn.innerHTML; - btn.innerHTML = 'Wysylam...'; - var csrfInput = document.querySelector('input[name="_token"]'); - var csrf = csrfInput ? csrfInput.value : ''; + // Print label button handler (delegated for dynamically added buttons) + document.addEventListener('click', function (e) { + var btn = e.target.closest('.btn-print-label'); + if (!btn) return; + var packageId = btn.getAttribute('data-package-id'); + if (!packageId) return; + btn.disabled = true; + var originalText = btn.innerHTML; + btn.innerHTML = 'Wysylam...'; + var csrfInput = document.querySelector('input[name="_token"]'); + var csrf = csrfInput ? csrfInput.value : ''; - fetch('/api/print/jobs', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: '_token=' + encodeURIComponent(csrf) + '&package_id=' + encodeURIComponent(packageId) - }) - .then(function (r) { return r.json().then(function (d) { return { status: r.status, data: d }; }); }) - .then(function (res) { - if (res.status === 201 || res.status === 409) { - btn.innerHTML = 'W kolejce'; - btn.disabled = true; - btn.classList.remove('btn--secondary'); - btn.classList.add('btn--danger'); - } else { - var msg = (res.data && res.data.error) ? res.data.error : 'Nieznany blad'; - if (window.OrderProAlerts) { window.OrderProAlerts.show({ message: msg, type: 'error' }); } - btn.innerHTML = originalText; - btn.disabled = false; - } - }) - .catch(function () { - if (window.OrderProAlerts) { window.OrderProAlerts.show({ message: 'Blad sieci.', type: 'error' }); } + fetch('/api/print/jobs', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: '_token=' + encodeURIComponent(csrf) + '&package_id=' + encodeURIComponent(packageId) + }) + .then(function (r) { return r.json().then(function (d) { return { status: r.status, data: d }; }); }) + .then(function (res) { + if (res.status === 201 || res.status === 409) { + btn.innerHTML = 'W kolejce'; + btn.disabled = true; + btn.classList.remove('btn--secondary'); + btn.classList.add('btn--danger'); + } else { + var msg = (res.data && res.data.error) ? res.data.error : 'Nieznany blad'; + if (window.OrderProAlerts) { window.OrderProAlerts.show({ message: msg, type: 'error' }); } btn.innerHTML = originalText; btn.disabled = false; - }); + } + }) + .catch(function () { + if (window.OrderProAlerts) { window.OrderProAlerts.show({ message: 'Blad sieci.', type: 'error' }); } + btn.innerHTML = originalText; + btn.disabled = false; }); }); @@ -805,4 +847,184 @@ foreach ($addressesList as $address) { })(); + + + + diff --git a/resources/views/settings/cron.php b/resources/views/settings/cron.php index e394bb9..0ef4f3e 100644 --- a/resources/views/settings/cron.php +++ b/resources/views/settings/cron.php @@ -90,42 +90,6 @@ $pastTotal = max(0, (int) ($pastPagination['total'] ?? 0)); -
-

-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ID
-
- -

diff --git a/routes/web.php b/routes/web.php index 85fe6e2..c306fde 100644 --- a/routes/web.php +++ b/routes/web.php @@ -261,7 +261,7 @@ return static function (Application $app): void { $shipmentPackageRepositoryForOrders ); $printJobRepository = new PrintJobRepository($app->db()); - $ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository, $emailSendingService, $emailTemplateRepository, $emailMailboxRepository, $app->basePath('storage'), $printJobRepository); + $ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository, $emailSendingService, $emailTemplateRepository, $emailMailboxRepository, $app->basePath('storage'), $printJobRepository, $shopproIntegrationsRepository); $receiptController = new ReceiptController( $template, $translator, @@ -483,6 +483,7 @@ return static function (Application $app): void { $router->get('/orders/{id}/shipment/{packageId}/status', [$shipmentController, 'checkStatus'], [$authMiddleware]); $router->post('/orders/{id}/shipment/{packageId}/label', [$shipmentController, 'label'], [$authMiddleware]); $router->post('/orders/{id}/shipment/manual', [$shipmentController, 'createManual'], [$authMiddleware]); + $router->post('/orders/{id}/payment/add', [$ordersController, 'addPayment'], [$authMiddleware]); // --- Printing module --- $printApiKeyRepository = new PrintApiKeyRepository($app->db()); diff --git a/src/Modules/Cron/CronHandlerFactory.php b/src/Modules/Cron/CronHandlerFactory.php index 130e544..72abf97 100644 --- a/src/Modules/Cron/CronHandlerFactory.php +++ b/src/Modules/Cron/CronHandlerFactory.php @@ -157,8 +157,7 @@ final class CronHandlerFactory new ApaczkaIntegrationRepository($this->db, $this->integrationSecret) ), new AllegroTrackingService( - $apiClient, - $tokenManager + new InpostIntegrationRepository($this->db, $this->integrationSecret) ), ]), new ShipmentPackageRepository($this->db), diff --git a/src/Modules/Orders/OrdersController.php b/src/Modules/Orders/OrdersController.php index 405b5c2..cba87b3 100644 --- a/src/Modules/Orders/OrdersController.php +++ b/src/Modules/Orders/OrdersController.php @@ -16,6 +16,8 @@ use App\Modules\Email\EmailSendingService; use App\Modules\Settings\EmailMailboxRepository; use App\Modules\Settings\EmailTemplateRepository; use App\Modules\Settings\ReceiptConfigRepository; +use App\Modules\Settings\ShopproApiClient; +use App\Modules\Settings\ShopproIntegrationsRepository; use App\Modules\Shipments\ShipmentPackageRepository; final class OrdersController @@ -32,7 +34,8 @@ final class OrdersController private readonly ?EmailTemplateRepository $emailTemplateRepo = null, private readonly ?EmailMailboxRepository $emailMailboxRepo = null, private readonly string $storagePath = '', - private readonly ?\App\Modules\Printing\PrintJobRepository $printJobRepo = null + private readonly ?\App\Modules\Printing\PrintJobRepository $printJobRepo = null, + private readonly ?ShopproIntegrationsRepository $shopproIntegrations = null ) { } @@ -781,4 +784,102 @@ final class OrdersController return Response::json($preview); } + public function addPayment(Request $request): Response + { + $orderId = max(0, (int) $request->input('id', 0)); + if ($orderId <= 0) { + return Response::json(['ok' => false, 'error' => 'Nieprawidłowe ID zamówienia.'], 400); + } + + if (!Csrf::verify((string) $request->input('_token', ''))) { + return Response::json(['ok' => false, 'error' => 'Nieprawidłowy token CSRF.'], 403); + } + + $amount = (float) $request->input('amount', 0); + $paymentTypeId = trim((string) $request->input('payment_type_id', '')); + $paymentDate = trim((string) $request->input('payment_date', '')); + $comment = trim((string) $request->input('comment', '')); + + if ($amount <= 0) { + return Response::json(['ok' => false, 'error' => 'Kwota musi być większa od 0.'], 422); + } + if ($paymentTypeId === '') { + return Response::json(['ok' => false, 'error' => 'Wybierz typ płatności.'], 422); + } + + $result = $this->orders->addPayment($orderId, [ + 'amount' => $amount, + 'payment_type_id' => $paymentTypeId, + 'payment_date' => $paymentDate !== '' ? $paymentDate . ' ' . date('H:i:s') : '', + 'comment' => $comment, + ]); + + if ($result === null) { + return Response::json(['ok' => false, 'error' => 'Nie udało się zapisać płatności.'], 500); + } + + $this->orders->recordActivity( + $orderId, + 'payment', + 'Dodano płatność: ' . number_format($amount, 2, '.', ' ') . ' PLN (' . $paymentTypeId . ')', + ['payment_id' => $result['id'], 'amount' => $amount, 'type' => $paymentTypeId], + 'user', + $this->auth->user()['name'] ?? null + ); + + $this->pushPaymentToShoppro($orderId, $result['payment_status']); + + return Response::json([ + 'ok' => true, + 'payment_id' => $result['id'], + 'payment_status' => $result['payment_status'], + 'total_paid' => $result['total_paid'], + ]); + } + + private function pushPaymentToShoppro(int $orderId, int $paymentStatus): void + { + if ($paymentStatus !== 2 || $this->shopproIntegrations === null) { + return; + } + + try { + $orderStmt = $this->orders->findOrderSourceInfo($orderId); + if ($orderStmt === null || ($orderStmt['source'] ?? '') !== 'shoppro') { + return; + } + + $integrationId = (int) ($orderStmt['integration_id'] ?? 0); + $sourceOrderId = trim((string) ($orderStmt['source_order_id'] ?? '')); + if ($integrationId <= 0 || $sourceOrderId === '') { + return; + } + + $integration = $this->shopproIntegrations->findIntegration($integrationId); + if ($integration === null || empty($integration['is_active']) || empty($integration['has_api_key'])) { + return; + } + + $baseUrl = trim((string) ($integration['base_url'] ?? '')); + $apiKey = $this->shopproIntegrations->getApiKeyDecrypted($integrationId); + if ($baseUrl === '' || $apiKey === null || trim($apiKey) === '') { + return; + } + + $client = new ShopproApiClient(); + $pushResult = $client->setOrderPaid($baseUrl, $apiKey, 10, $sourceOrderId); + + $this->orders->recordActivity( + $orderId, + 'sync', + $pushResult['ok'] + ? 'Wysłano status płatności do shopPRO (opłacone)' + : 'Błąd push płatności do shopPRO: ' . ($pushResult['message'] ?? 'unknown'), + ['direction' => 'push', 'target' => 'shoppro', 'ok' => $pushResult['ok']], + 'system' + ); + } catch (\Throwable) { + } + } + } diff --git a/src/Modules/Orders/OrdersRepository.php b/src/Modules/Orders/OrdersRepository.php index 4257b0c..e3deb5d 100644 --- a/src/Modules/Orders/OrdersRepository.php +++ b/src/Modules/Orders/OrdersRepository.php @@ -847,6 +847,94 @@ final class OrdersRepository ], $actorType, $actorName); } + /** + * @param array $data Keys: payment_type_id, amount, payment_date, comment, currency + * @return array{id:int, payment_status:int, total_paid:float}|null + */ + /** + * @return array{source:string, integration_id:int, source_order_id:string}|null + */ + public function findOrderSourceInfo(int $orderId): ?array + { + if ($orderId <= 0) { + return null; + } + $stmt = $this->pdo->prepare('SELECT source, integration_id, source_order_id FROM orders WHERE id = :id LIMIT 1'); + $stmt->execute(['id' => $orderId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return is_array($row) ? $row : null; + } + + /** + * @param array $data Keys: payment_type_id, amount, payment_date, comment, currency + * @return array{id:int, payment_status:int, total_paid:float}|null + */ + public function addPayment(int $orderId, array $data): ?array + { + if ($orderId <= 0) { + return null; + } + + $stmt = $this->pdo->prepare('SELECT id, total_with_tax, currency FROM orders WHERE id = :id LIMIT 1'); + $stmt->execute(['id' => $orderId]); + $order = $stmt->fetch(PDO::FETCH_ASSOC); + if (!is_array($order)) { + return null; + } + + $amount = round((float) ($data['amount'] ?? 0), 2); + $paymentTypeId = trim((string) ($data['payment_type_id'] ?? '')); + $paymentDate = trim((string) ($data['payment_date'] ?? '')); + $comment = trim((string) ($data['comment'] ?? '')); + $currency = trim((string) ($data['currency'] ?? $order['currency'] ?? 'PLN')); + + if ($amount <= 0 || $paymentTypeId === '') { + return null; + } + + $sourcePaymentId = 'manual_' . $orderId . '_' . time(); + + $insert = $this->pdo->prepare( + 'INSERT INTO order_payments (order_id, source_payment_id, payment_type_id, payment_date, amount, currency, comment, created_at, updated_at) + VALUES (:order_id, :source_payment_id, :payment_type_id, :payment_date, :amount, :currency, :comment, NOW(), NOW())' + ); + $insert->execute([ + 'order_id' => $orderId, + 'source_payment_id' => $sourcePaymentId, + 'payment_type_id' => $paymentTypeId, + 'payment_date' => $paymentDate !== '' ? $paymentDate : date('Y-m-d H:i:s'), + 'amount' => $amount, + 'currency' => $currency, + 'comment' => $comment !== '' ? $comment : null, + ]); + $paymentId = (int) $this->pdo->lastInsertId(); + + $sumStmt = $this->pdo->prepare('SELECT COALESCE(SUM(amount), 0) FROM order_payments WHERE order_id = :order_id'); + $sumStmt->execute(['order_id' => $orderId]); + $totalPaid = round((float) $sumStmt->fetchColumn(), 2); + + $totalWithTax = $order['total_with_tax'] !== null ? (float) $order['total_with_tax'] : null; + $paymentStatus = 0; + if ($totalPaid > 0 && $totalWithTax !== null && $totalPaid >= $totalWithTax) { + $paymentStatus = 2; + } elseif ($totalPaid > 0) { + $paymentStatus = 1; + } + + $update = $this->pdo->prepare('UPDATE orders SET payment_status = :payment_status, total_paid = :total_paid, updated_at = NOW() WHERE id = :id'); + $update->execute([ + 'payment_status' => $paymentStatus, + 'total_paid' => $totalPaid, + 'id' => $orderId, + ]); + + return [ + 'id' => $paymentId, + 'payment_status' => $paymentStatus, + 'total_paid' => $totalPaid, + ]; + } + public function updateOrderStatus(int $orderId, string $newStatusCode, string $actorType = 'user', ?string $actorName = null): bool { try { diff --git a/src/Modules/Settings/AllegroIntegrationController.php b/src/Modules/Settings/AllegroIntegrationController.php index 296e8cb..d3edf5a 100644 --- a/src/Modules/Settings/AllegroIntegrationController.php +++ b/src/Modules/Settings/AllegroIntegrationController.php @@ -36,6 +36,7 @@ final class AllegroIntegrationController ]; private const OAUTH_SCOPES = [ AllegroOAuthClient::ORDERS_READ_SCOPE, + AllegroOAuthClient::ORDERS_WRITE_SCOPE, AllegroOAuthClient::SALE_OFFERS_READ_SCOPE, AllegroOAuthClient::SHIPMENTS_READ_SCOPE, AllegroOAuthClient::SHIPMENTS_WRITE_SCOPE, diff --git a/src/Modules/Settings/AllegroOAuthClient.php b/src/Modules/Settings/AllegroOAuthClient.php index 0a8398b..d335896 100644 --- a/src/Modules/Settings/AllegroOAuthClient.php +++ b/src/Modules/Settings/AllegroOAuthClient.php @@ -8,6 +8,7 @@ use App\Core\Exceptions\AllegroOAuthException; final class AllegroOAuthClient { public const ORDERS_READ_SCOPE = 'allegro:api:orders:read'; + public const ORDERS_WRITE_SCOPE = 'allegro:api:orders:write'; 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'; diff --git a/src/Modules/Settings/ShopproApiClient.php b/src/Modules/Settings/ShopproApiClient.php index c44bce5..d9f3a55 100644 --- a/src/Modules/Settings/ShopproApiClient.php +++ b/src/Modules/Settings/ShopproApiClient.php @@ -249,6 +249,34 @@ final class ShopproApiClient ]; } + /** + * @return array{ok:bool,http_code:int|null,message:string} + */ + public function setOrderPaid( + string $baseUrl, + string $apiKey, + int $timeoutSeconds, + string $sourceOrderId + ): array { + if ($sourceOrderId === '') { + return ['ok' => false, 'http_code' => null, 'message' => 'Brak source_order_id.']; + } + + $url = rtrim(trim($baseUrl), '/') . '/api.php?' . http_build_query([ + 'endpoint' => 'orders', + 'action' => 'set_paid', + 'id' => $sourceOrderId, + ]); + + $response = $this->requestJsonPut($url, $apiKey, $timeoutSeconds, json_encode(['send_email' => 0], JSON_THROW_ON_ERROR)); + + return [ + 'ok' => ($response['ok'] ?? false) === true, + 'http_code' => $response['http_code'] ?? null, + 'message' => (string) ($response['message'] ?? ''), + ]; + } + /** * @return array{ok:bool,http_code:int|null,message:string,data:array|array|null} */ diff --git a/src/Modules/Shipments/AllegroShipmentService.php b/src/Modules/Shipments/AllegroShipmentService.php index c0c5d1a..ed25699 100644 --- a/src/Modules/Shipments/AllegroShipmentService.php +++ b/src/Modules/Shipments/AllegroShipmentService.php @@ -86,7 +86,7 @@ final class AllegroShipmentService implements ShipmentProviderInterface 'deliveryMethodId' => $deliveryMethodId, 'sender' => $senderAddress, 'receiver' => $receiverAddress, - 'referenceNumber' => $sourceOrderId !== '' ? $sourceOrderId : (string) $orderId, + 'referenceNumber' => substr($sourceOrderId !== '' ? $sourceOrderId : (string) $orderId, 0, 35), 'packages' => [[ 'type' => $packageType, 'length' => ['value' => $lengthCm, 'unit' => 'CENTIMETER'], @@ -140,7 +140,7 @@ final class AllegroShipmentService implements ShipmentProviderInterface '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, + 'reference_number' => substr($sourceOrderId !== '' ? $sourceOrderId : (string) $orderId, 0, 35), 'payload_json' => $apiPayload, ]); @@ -196,10 +196,11 @@ final class AllegroShipmentService implements ShipmentProviderInterface if ($status === 'SUCCESS' && $shipmentId !== '') { $details = $this->apiClient->getShipmentDetails($env, $accessToken, $shipmentId); - $trackingNumber = trim((string) ($details['waybill'] ?? '')); + $detailPackages = is_array($details['packages'] ?? null) ? $details['packages'] : []; + $trackingNumber = trim((string) ($detailPackages[0]['waybill'] ?? '')); $carrierId = trim((string) ($package['carrier_id'] ?? '')); if ($carrierId === '') { - $carrierId = trim((string) ($details['carrierId'] ?? '')); + $carrierId = trim((string) ($details['carrier'] ?? '')); } $this->packages->update($packageId, [ @@ -264,7 +265,7 @@ final class AllegroShipmentService implements ShipmentProviderInterface [$accessToken, $env] = $this->tokenManager->resolveToken(); $labelFormat = trim((string) ($package['label_format'] ?? 'PDF')); - $pageSize = $labelFormat === 'ZPL' ? 'A6' : 'A4'; + $pageSize = 'A6'; $binary = $this->apiClient->getShipmentLabel($env, $accessToken, [$shipmentId], $pageSize); $dir = rtrim($storagePath, '/\\') . '/labels'; @@ -286,10 +287,11 @@ final class AllegroShipmentService implements ShipmentProviderInterface if (trim((string) ($package['tracking_number'] ?? '')) === '') { try { $details = $this->apiClient->getShipmentDetails($env, $accessToken, $shipmentId); - $trackingNumber = trim((string) ($details['waybill'] ?? '')); + $detailPackages = is_array($details['packages'] ?? null) ? $details['packages'] : []; + $trackingNumber = trim((string) ($detailPackages[0]['waybill'] ?? '')); $carrierId = trim((string) ($package['carrier_id'] ?? '')); if ($carrierId === '') { - $carrierId = trim((string) ($details['carrierId'] ?? '')); + $carrierId = trim((string) ($details['carrier'] ?? '')); } if ($trackingNumber !== '') { diff --git a/src/Modules/Shipments/AllegroTrackingService.php b/src/Modules/Shipments/AllegroTrackingService.php index 4f8cbfe..50e4018 100644 --- a/src/Modules/Shipments/AllegroTrackingService.php +++ b/src/Modules/Shipments/AllegroTrackingService.php @@ -3,15 +3,16 @@ declare(strict_types=1); namespace App\Modules\Shipments; -use App\Modules\Settings\AllegroApiClient; -use App\Modules\Settings\AllegroTokenManager; +use App\Modules\Settings\InpostIntegrationRepository; use Throwable; final class AllegroTrackingService implements ShipmentTrackingInterface { + private const INPOST_API_PRODUCTION = 'https://api-shipx-pl.easypack24.net/v1'; + private const INPOST_API_SANDBOX = 'https://sandbox-api-shipx-pl.easypack24.net/v1'; + public function __construct( - private readonly AllegroApiClient $apiClient, - private readonly AllegroTokenManager $tokenManager + private readonly InpostIntegrationRepository $inpostRepository ) { } @@ -22,32 +23,122 @@ final class AllegroTrackingService implements ShipmentTrackingInterface public function getDeliveryStatus(array $package): ?array { - $shipmentId = trim((string) ($package['shipment_id'] ?? '')); - if ($shipmentId === '') { + $trackingNumber = trim((string) ($package['tracking_number'] ?? '')); + if ($trackingNumber === '') { return null; } - return $this->fetchStatus($shipmentId); + $carrierId = strtolower(trim((string) ($package['carrier_id'] ?? ''))); + + if (str_contains($carrierId, 'inpost') || str_contains($carrierId, 'paczkomat')) { + return $this->fetchInpostStatus($trackingNumber); + } + + // Allegro Delivery (One Kurier), DHL, DPD via Allegro — brak publicznego API trackingu + return null; } - private function fetchStatus(string $shipmentId): ?array + private function fetchInpostStatus(string $trackingNumber): ?array { try { - [$accessToken, $env] = $this->tokenManager->resolveToken(); - $details = $this->apiClient->getShipmentDetails($env, $accessToken, $shipmentId); + $token = $this->resolveInpostToken(); + if ($token === null) { + return null; + } - $rawStatus = strtoupper(trim((string) ($details['status'] ?? ''))); + $settings = $this->inpostRepository->getSettings(); + $env = (string) ($settings['environment'] ?? 'sandbox'); + $baseUrl = strtolower(trim($env)) === 'production' + ? self::INPOST_API_PRODUCTION + : self::INPOST_API_SANDBOX; + + $url = $baseUrl . '/tracking/' . rawurlencode($trackingNumber); + $response = $this->apiRequest($url, $token); + + $details = is_array($response['tracking_details'] ?? null) ? $response['tracking_details'] : []; + if ($details === []) { + return null; + } + + $rawStatus = strtolower(trim((string) ($details[0]['status'] ?? ''))); if ($rawStatus === '') { return null; } return [ - 'status' => DeliveryStatus::normalize('allegro_wza', $rawStatus), + 'status' => DeliveryStatus::normalize('inpost', $rawStatus), 'status_raw' => $rawStatus, - 'description' => DeliveryStatus::description('allegro_wza', $rawStatus), + 'description' => DeliveryStatus::description('inpost', $rawStatus), ]; } catch (Throwable) { return null; } } + + private function resolveInpostToken(): ?string + { + try { + $token = $this->inpostRepository->getDecryptedToken(); + return ($token !== null && trim($token) !== '') ? trim($token) : null; + } catch (Throwable) { + return null; + } + } + + /** + * @return array + */ + private function apiRequest(string $url, string $token): array + { + $ch = curl_init($url); + if ($ch === false) { + return []; + } + + $opts = [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $token, + 'Accept: application/json', + ], + ]; + + $caPath = $this->getCaBundlePath(); + if ($caPath !== null) { + $opts[CURLOPT_CAINFO] = $caPath; + } + + curl_setopt_array($ch, $opts); + $body = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $ch = null; + + if ($body === false || $httpCode < 200 || $httpCode >= 300) { + return []; + } + + $json = json_decode((string) $body, true); + return is_array($json) ? $json : []; + } + + private function getCaBundlePath(): ?string + { + $candidates = [ + (string) ($_ENV['CURL_CA_BUNDLE_PATH'] ?? ''), + (string) ini_get('curl.cainfo'), + 'C:/xampp/apache/bin/curl-ca-bundle.crt', + 'C:/xampp/php/extras/ssl/cacert.pem', + '/etc/ssl/certs/ca-certificates.crt', + ]; + foreach ($candidates as $path) { + if ($path !== '' && is_file($path)) { + return $path; + } + } + return null; + } } diff --git a/src/Modules/Shipments/DeliveryStatus.php b/src/Modules/Shipments/DeliveryStatus.php index 721956a..6091340 100644 --- a/src/Modules/Shipments/DeliveryStatus.php +++ b/src/Modules/Shipments/DeliveryStatus.php @@ -308,10 +308,6 @@ final class DeliveryStatus return 'https://inpost.pl/sledzenie-przesylek?number=' . $encoded; } - if ($provider === 'allegro_wza') { - return 'https://allegro.pl/przesylka/' . $encoded; - } - if ($carrierId !== '') { $url = self::matchCarrierByName($encoded, strtolower(trim($carrierId))); if ($url !== null) { @@ -319,6 +315,10 @@ final class DeliveryStatus } } + if ($provider === 'allegro_wza') { + return 'https://allegro.pl/allegrodelivery/sledzenie-paczki?numer=' . $encoded; + } + return 'https://www.google.com/search?q=' . $encoded . '+sledzenie+przesylki'; } @@ -348,6 +348,9 @@ final class DeliveryStatus if (str_contains($carrier, 'gls')) { return 'https://gls-group.com/PL/pl/sledzenie-paczek?match=' . $encoded; } + if ($carrier === 'allegro') { + return 'https://allegro.pl/allegrodelivery/sledzenie-paczki?numer=' . $encoded; + } return null; }