From 62a68e9ec2c1071adc11edbb6d3a149b4bb9e4f9 Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Sat, 14 Mar 2026 01:10:29 +0100 Subject: [PATCH] update --- .env.example | 5 + .paul/HANDOFF-2026-03-13.md | 74 ----- .paul/STATE.md | 44 ++- .paul/handoffs/archive/HANDOFF-2026-03-13.md | 60 ++-- .../07-pre-expansion-fixes/07-01-SUMMARY.md | 122 +++++++++ .../07-pre-expansion-fixes/07-02-SUMMARY.md | 132 +++++++++ .../07-pre-expansion-fixes/07-03-SUMMARY.md | 145 ++++++++++ .vscode/ftp-kr.sync.cache.json | 258 ++++++++++++------ DOCS/DB_SCHEMA.md | 3 + DOCS/TECH_CHANGELOG.md | 14 + ..._000048_add_orders_performance_indexes.sql | 8 + ...14_000049_add_cron_last_run_at_setting.sql | 3 + public/assets/css/app.css | 2 +- resources/scss/shared/_ui-components.scss | 2 +- resources/views/settings/allegro.php | 4 +- src/Core/Application.php | 30 +- src/Modules/Orders/OrdersController.php | 53 +++- src/Modules/Orders/OrdersRepository.php | 34 ++- src/Modules/Settings/AllegroApiClient.php | 50 +++- src/Modules/Settings/AllegroOAuthClient.php | 34 ++- .../Settings/AllegroStatusSyncService.php | 2 +- src/Modules/Settings/ApaczkaApiClient.php | 34 ++- src/Modules/Settings/ShopproApiClient.php | 34 ++- 23 files changed, 908 insertions(+), 239 deletions(-) delete mode 100644 .paul/HANDOFF-2026-03-13.md create mode 100644 .paul/phases/07-pre-expansion-fixes/07-01-SUMMARY.md create mode 100644 .paul/phases/07-pre-expansion-fixes/07-02-SUMMARY.md create mode 100644 .paul/phases/07-pre-expansion-fixes/07-03-SUMMARY.md create mode 100644 database/migrations/20260314_000048_add_orders_performance_indexes.sql create mode 100644 database/migrations/20260314_000049_add_cron_last_run_at_setting.sql diff --git a/.env.example b/.env.example index e68943e..aedd4fb 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,8 @@ DB_DATABASE=orderpro DB_USERNAME=root DB_PASSWORD= DB_CHARSET=utf8mb4 + +# SSL/TLS — sciezka do CA bundle dla cURL +# Windows XAMPP: C:/xampp/php/extras/ssl/cacert.pem +# Linux: /etc/ssl/certs/ca-certificates.crt +CURL_CA_BUNDLE_PATH= diff --git a/.paul/HANDOFF-2026-03-13.md b/.paul/HANDOFF-2026-03-13.md deleted file mode 100644 index 95225b1..0000000 --- a/.paul/HANDOFF-2026-03-13.md +++ /dev/null @@ -1,74 +0,0 @@ -# PAUL Handoff - -**Date:** 2026-03-13 -**Status:** paused — session complete, plans ready for execution - ---- - -## READ THIS FIRST - -You have no prior context. This document tells you everything. - -**Project:** orderPRO — aplikacja do zarządzania zamówieniami z wielu źródeł sprzedaży (Allegro, Erli, shopPRO) z generowaniem etykiet przewozowych. -**Core value:** Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i nadawać przesyłki bez przełączania się między platformami. - ---- - -## Current State - -**Milestone:** v0.2 Pre-Expansion Fixes -**Phase:** 7 — pre-expansion-fixes -**Plan:** 07-01..07-05 CREATED, żaden nie wykonany - -**Loop Position:** -``` -PLAN ──▶ APPLY ──▶ UNIFY - ✓ ○ ○ [Plan gotowy — czeka na APPLY] -``` - ---- - -## What Was Done (ta sesja) - -- UNIFY 06-05: god classes split — ShopproOrdersSyncService 39→9 metod, AllegroIntegrationController 35→25 metod -- Faza 06 zamknięta: 6/6 planów, SonarQube quality baseline -- /paul:complete-milestone: v0.1 Initial Release zamknięty, git tag v0.1.0 -- CONCERNS.md skategoryzowany — "przed rozbudową" vs "odroczić" -- Faza 07 zaplanowana: 5 planów (07-01..07-05) gotowe do APPLY - ---- - -## What's Next - -**Immediate:** `/paul:apply .paul/phases/07-pre-expansion-fixes/07-01-PLAN.md` - -Kolejność: -1. 07-01 — autonomiczny (Performance: N+1, static cache, DB indexes) -2. 07-02 — autonomiczny (SSL verify, cron→DB, migration 000014b) -3. 07-03 — ma checkpoint:human-verify (UX: disable orderpro_to_allegro, UI items 14-17) -4. 07-04 — autonomiczny (Tests: AllegroTokenManager + import) -5. 07-05 — ma checkpoint:decision (InPost ShipmentProviderInterface) - ---- - -## Key Files - -| File | Purpose | -|------|---------| -| `.paul/STATE.md` | Live project state | -| `.paul/phases/07-pre-expansion-fixes/07-01-PLAN.md` | Performance fixes | -| `.paul/phases/07-pre-expansion-fixes/07-02-PLAN.md` | SSL + cron + migration | -| `.paul/phases/07-pre-expansion-fixes/07-03-PLAN.md` | UX fixes (checkpoint) | -| `.paul/phases/07-pre-expansion-fixes/07-04-PLAN.md` | Unit tests | -| `.paul/phases/07-pre-expansion-fixes/07-05-PLAN.md` | InPost provider (checkpoint:decision) | - ---- - -## Resume Instructions - -1. `/paul:resume` — odczyta STATE.md i pokaże aktualny stan -2. Zatwierdź: `/paul:apply .paul/phases/07-pre-expansion-fixes/07-01-PLAN.md` - ---- - -*Handoff created: 2026-03-13* diff --git a/.paul/STATE.md b/.paul/STATE.md index 2157482..7cdf8c0 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -5,26 +5,26 @@ See: .paul/PROJECT.md (updated 2026-03-12) **Core value:** Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i nadawać przesyłki bez przełączania się między platformami. -**Current focus:** Faza 07 — Pre-Expansion Fixes. 5 planów utworzonych, gotowe do APPLY. +**Current focus:** Faza 07 — Pre-Expansion Fixes. Plan 07-03 UNIFY complete, 07-04 następny. ## Current Position Milestone: v0.2 Pre-Expansion Fixes -Phase: 7 of TBD (07-pre-expansion-fixes) — Planning -Plan: 07-01 do 07-05 CREATED, awaiting approval -Status: PLANy gotowe — wybrać plan do APPLY -Last activity: 2026-03-13 — Faza 07 zaplanowana (5 planów) +Phase: 7 of TBD (07-pre-expansion-fixes) — Executing +Plan: 07-01 ✓, 07-02 ✓, 07-03 ✓, 07-04..07-05 awaiting +Status: Loop 07-03 zamknięty — następny /paul:apply 07-04 +Last activity: 2026-03-14 — Plan 07-03 loop closed (UX fixes + SSL hotfix) Progress: - v0.1 Initial Release: [██████████] 100% ✓ -- v0.2 Pre-Expansion Fixes: [░░░░░░░░░░] 0% (0/5 planów) +- v0.2 Pre-Expansion Fixes: [██████░░░░] 60% (3/5 planów) ## Loop Position Current loop state: ``` PLAN ──▶ APPLY ──▶ UNIFY - ✓ ○ ○ [07-01 plan gotowy — zacznij od /paul:apply 07-01] + ✓ ✓ ✓ [07-03 complete — next: /paul:apply 07-04] ``` ## Accumulated Context @@ -39,6 +39,21 @@ PLAN ──▶ APPLY ──▶ UNIFY | 2026-03-13 | Flash messages: Flash::set('module.type') / Flash::get('module.type', '') | Faza 05 | OrdersController i ShipmentController zmigrowane; jeden wzorzec w całej aplikacji | | 2026-03-13 | validateXxxInput(): ?string i validateXxxAccess(): ?Response jako wzorce helperów walidacji | Faza 06 | Redukcja return statements do ≤3; wzorzec do użycia w kolejnych planach | +### Skill Audit (Faza 07, Plan 03) +| Oczekiwany | Wywołany | Uwagi | +|------------|---------|-------| +| sonar-scanner | ○ | Pominięto — brak instalacji w PATH | + +### Skill Audit (Faza 07, Plan 02) +| Oczekiwany | Wywołany | Uwagi | +|------------|---------|-------| +| sonar-scanner | ○ | Pominięto — brak instalacji w PATH | + +### Skill Audit (Faza 07, Plan 01) +| Oczekiwany | Wywołany | Uwagi | +|------------|---------|-------| +| sonar-scanner | ○ | Pominięto — brak instalacji w PATH | + ### Skill Audit (Faza 06, Plan 06) | Oczekiwany | Wywołany | Uwagi | |------------|---------|-------| @@ -80,6 +95,7 @@ PLAN ──▶ APPLY ──▶ UNIFY ### Deferred Issues - **CI/CD SonarQube** — dodać GitHub Actions workflow (`.github/workflows/sonarqube.yml`) który odpala `sonar-scanner` automatycznie przy każdym pushu. Token projektu: `sqp_8ef2748d037777cf00cf1b38534f8d435b762d7d` (dodać jako GitHub Secret `SONAR_TOKEN`). Przypisać do fazy związanej z infrastrukturą/DevOps gdy tylko fazy zostaną zdefiniowane. - **code-review** — wywołać /code-review przed kolejnym UNIFY (pominięto w obydwu planach fazy 01). +- **Delivery mapping "Szukaj..." layout** — JS `attachSelectFilter()` w allegro.php tworzy input search dla InPost/Apaczka selectów, wizualnie wygląda jakby należał do wiersza powyżej. Pre-existing bug, do naprawy osobno. ### Git State Last commit: fbb3020 (docs: close loop 06-05 — UNIFY complete) @@ -91,14 +107,14 @@ Brak. ## Session Continuity -Last session: 2026-03-13 -Stopped at: Faza 07 zaplanowana — 5 planów gotowych do wykonania -Next action: /paul:apply .paul/phases/07-pre-expansion-fixes/07-01-PLAN.md -Resume file: .paul/phases/07-pre-expansion-fixes/07-01-PLAN.md +Last session: 2026-03-14 +Stopped at: Loop 07-03 zamknięty — SUMMARY utworzony +Next action: /paul:apply .paul/phases/07-pre-expansion-fixes/07-04-PLAN.md +Resume file: .paul/phases/07-pre-expansion-fixes/07-03-SUMMARY.md Resume context: -- 07-01: Performance (N+1 subqueries, information_schema static, DB indexes) -- 07-02: SSL verification + cron throttle DB + migration 000014b -- 07-03: UX fixes (orderpro_to_allegro disable, items 14-17) — ma checkpoint +- 07-01: COMPLETE ✓ (N+1→LEFT JOIN, static cache, migration 000048) +- 07-02: COMPLETE ✓ (SSL verification, cron→DB, migration 000014b) +- 07-03: COMPLETE ✓ (UX fixes + SSL hotfix CA bundle nullable) - 07-04: Tests (AllegroTokenManager + AllegroOrderImportService) - 07-05: InPost ShipmentProviderInterface — ma checkpoint:decision diff --git a/.paul/handoffs/archive/HANDOFF-2026-03-13.md b/.paul/handoffs/archive/HANDOFF-2026-03-13.md index 095b574..95225b1 100644 --- a/.paul/handoffs/archive/HANDOFF-2026-03-13.md +++ b/.paul/handoffs/archive/HANDOFF-2026-03-13.md @@ -1,7 +1,7 @@ # PAUL Handoff **Date:** 2026-03-13 -**Status:** paused +**Status:** paused — session complete, plans ready for execution --- @@ -9,50 +9,45 @@ You have no prior context. This document tells you everything. -**Project:** orderPRO — aplikacja do zarządzania zamówieniami z wielu kanałów sprzedaży (Allegro, Erli, własne sklepy). Generowanie etykiet kurierskich. -**Core value:** Sprzedawca obsługuje wszystkie kanały i nadaje przesyłki bez przełączania platform. +**Project:** orderPRO — aplikacja do zarządzania zamówieniami z wielu źródeł sprzedaży (Allegro, Erli, shopPRO) z generowaniem etykiet przewozowych. +**Core value:** Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i nadawać przesyłki bez przełączania się między platformami. --- ## Current State -**Version:** v0.1.0 (In Progress) -**Phase:** 6 of TBD — 06-sonarqube-quality -**Plan:** 06-01 ✓ DONE, 06-03 ✓ DONE, 06-02/04/05/06 awaiting +**Milestone:** v0.2 Pre-Expansion Fixes +**Phase:** 7 — pre-expansion-fixes +**Plan:** 07-01..07-05 CREATED, żaden nie wykonany **Loop Position:** ``` PLAN ──▶ APPLY ──▶ UNIFY - ✓ ✓ ✓ [loop closed — ready for next plan] + ✓ ○ ○ [Plan gotowy — czeka na APPLY] ``` --- -## What Was Done (this session) +## What Was Done (ta sesja) -- **UNIFY 06-01** — zamknięto pętlę dla exception hierarchy (commit 3c27c4e) -- **APPLY 06-03** — IntegrationSources + RedirectPaths constants created; 30+ literals replaced (commit d7d3f99) - - Auto-fix: naprawiono złamane `use AppCoreExceptions...` z planu 06-01 w AllegroOrderImportService + AllegroIntegrationController -- **UNIFY 06-03** — pętla zamknięta, SUMMARY.md utworzony -- Phase 6 progress: 2/6 plans complete (33%) - ---- - -## What's In Progress - -Nic — obie pętle zamknięte, codebase w stabilnym stanie. +- UNIFY 06-05: god classes split — ShopproOrdersSyncService 39→9 metod, AllegroIntegrationController 35→25 metod +- Faza 06 zamknięta: 6/6 planów, SonarQube quality baseline +- /paul:complete-milestone: v0.1 Initial Release zamknięty, git tag v0.1.0 +- CONCERNS.md skategoryzowany — "przed rozbudową" vs "odroczić" +- Faza 07 zaplanowana: 5 planów (07-01..07-05) gotowe do APPLY --- ## What's Next -**Immediate:** `/paul:apply .paul/phases/06-sonarqube-quality/06-02-PLAN.md` -— php:S1142 redukcja return statements (save() z 5→≤3 w AllegroIntegrationController + ShopproIntegrationsController) +**Immediate:** `/paul:apply .paul/phases/07-pre-expansion-fixes/07-01-PLAN.md` -**Kolejność pozostałych planów:** 06-02 → 06-06 → 06-04 → 06-05 -- 06-05 (god classes) zależy od 06-04 i ma `checkpoint:human-verify` (nie autonomous) - -**Uwaga:** Warto sprawdzić czy broken `use` statements z 06-01 są w innych plikach Modules/Settings/ (wzorzec: `use AppCore...` bez backslashy) +Kolejność: +1. 07-01 — autonomiczny (Performance: N+1, static cache, DB indexes) +2. 07-02 — autonomiczny (SSL verify, cron→DB, migration 000014b) +3. 07-03 — ma checkpoint:human-verify (UX: disable orderpro_to_allegro, UI items 14-17) +4. 07-04 — autonomiczny (Tests: AllegroTokenManager + import) +5. 07-05 — ma checkpoint:decision (InPost ShipmentProviderInterface) --- @@ -61,19 +56,18 @@ Nic — obie pętle zamknięte, codebase w stabilnym stanie. | File | Purpose | |------|---------| | `.paul/STATE.md` | Live project state | -| `.paul/ROADMAP.md` | Phase overview | -| `.paul/phases/06-sonarqube-quality/06-02-PLAN.md` | S1142: Redukcja return statements | -| `.paul/phases/06-sonarqube-quality/06-03-SUMMARY.md` | Ostatni UNIFY — context dla 06-02 | -| `src/Core/Constants/IntegrationSources.php` | Nowa klasa stałych (ALLEGRO, SHOPPRO, etc.) | -| `src/Core/Constants/RedirectPaths.php` | Nowa klasa stałych (redirect paths) | -| `.paul/codebase/CONCERNS.md` | Pełna lista concerns | +| `.paul/phases/07-pre-expansion-fixes/07-01-PLAN.md` | Performance fixes | +| `.paul/phases/07-pre-expansion-fixes/07-02-PLAN.md` | SSL + cron + migration | +| `.paul/phases/07-pre-expansion-fixes/07-03-PLAN.md` | UX fixes (checkpoint) | +| `.paul/phases/07-pre-expansion-fixes/07-04-PLAN.md` | Unit tests | +| `.paul/phases/07-pre-expansion-fixes/07-05-PLAN.md` | InPost provider (checkpoint:decision) | --- ## Resume Instructions -1. Przeczytaj `.paul/STATE.md` — potwierdź pozycję w loop -2. Uruchom `/paul:apply .paul/phases/06-sonarqube-quality/06-02-PLAN.md` +1. `/paul:resume` — odczyta STATE.md i pokaże aktualny stan +2. Zatwierdź: `/paul:apply .paul/phases/07-pre-expansion-fixes/07-01-PLAN.md` --- diff --git a/.paul/phases/07-pre-expansion-fixes/07-01-SUMMARY.md b/.paul/phases/07-pre-expansion-fixes/07-01-SUMMARY.md new file mode 100644 index 0000000..6cf7de2 --- /dev/null +++ b/.paul/phases/07-pre-expansion-fixes/07-01-SUMMARY.md @@ -0,0 +1,122 @@ +--- +phase: 07-pre-expansion-fixes +plan: 01 +subsystem: database +tags: [performance, n+1, sql-optimization, indexes, static-cache] + +requires: + - phase: 06-sonarqube-quality + provides: OrdersRepository refactored (god class split) +provides: + - Aggregating LEFT JOINs replacing N+1 correlated subqueries in orders list + - Static cache for information_schema check + - Performance indexes on orders table +affects: [07-02, 07-03, 07-04] + +tech-stack: + added: [] + patterns: [aggregating-left-join, static-property-cache] + +key-files: + created: + - database/migrations/20260314_000048_add_orders_performance_indexes.sql + modified: + - src/Modules/Orders/OrdersRepository.php + +key-decisions: + - "allegro_order_status_mappings index skipped — already has UNIQUE KEY on allegro_status_code" + - "Migration date 20260314 (execution date, not plan date)" + +patterns-established: + - "Aggregating LEFT JOIN zamiast correlated subqueries dla countów/sum w listach" + - "Static property cache dla information_schema lookups" + +duration: ~8min +started: 2026-03-14 +completed: 2026-03-14 +--- + +# Phase 7 Plan 01: Performance Fixes Summary + +**Eliminacja N+1 subqueries w liście zamówień, static cache dla information_schema, indeksy wydajnościowe na tabeli orders** + +## Performance + +| Metric | Value | +|--------|-------| +| Duration | ~8min | +| Started | 2026-03-14 | +| Completed | 2026-03-14 | +| Tasks | 3 completed | +| Files modified | 2 (1 modified, 1 created) | + +## Acceptance Criteria Results + +| Criterion | Status | Notes | +|-----------|--------|-------| +| AC-1: Brak correlated subqueries w liście zamówień | Pass | 4 subqueries zastąpione aggregating LEFT JOINs; grep potwierdza zero `SELECT COUNT(*) FROM order_items WHERE oi.order_id` | +| AC-2: information_schema nie odpytywany per-request | Pass | `private static ?bool $supportsMappedMedia` + `self::$supportsMappedMedia` — max 1× na cykl PHP | +| AC-3: Brakujące indeksy dodane migracją | Pass | Migracja 000048: source, external_status_id, ordered_at, composite (source, external_status_id); IF NOT EXISTS | + +## Accomplishments + +- 4 correlated subqueries (items_count, items_qty, shipments_count, documents_count) → 3 aggregating LEFT JOINs — eliminacja ~200 dodatkowych zapytań przy 50 wierszach na stronę +- `canResolveMappedMedia()` z instance na static property — information_schema odpytywany max 1× na cykl PHP +- 4 brakujące indeksy na tabeli orders dla typowych filtrów/sortowań + +## Files Created/Modified + +| File | Change | Purpose | +|------|--------|---------| +| `src/Modules/Orders/OrdersRepository.php` | Modified | LEFT JOINs zamiast subqueries w buildListSql(); static cache w canResolveMappedMedia() | +| `database/migrations/20260314_000048_add_orders_performance_indexes.sql` | Created | Indeksy: source, external_status_id, ordered_at, composite (source, external_status_id) | +| `DOCS/DB_SCHEMA.md` | Modified | Wpis o migracji 000048 | +| `DOCS/TECH_CHANGELOG.md` | Modified | Wpis 2026-03-14 o optymalizacjach | + +## Decisions Made + +| Decision | Rationale | Impact | +|----------|-----------|--------| +| Pominięto indeks na allegro_order_status_mappings.allegro_status_code | Tabela już ma UNIQUE KEY na tej kolumnie (migracja 000025) | Brak duplikatu indeksu | +| Data migracji 20260314 zamiast 20260313 | Wykonanie w dniu 2026-03-14 | Numeracja zgodna z datą faktycznego utworzenia | + +## Deviations from Plan + +### Summary + +| Type | Count | Impact | +|------|-------|--------| +| Auto-fixed | 0 | - | +| Scope additions | 0 | - | +| Deferred | 0 | - | + +**Total impact:** Plan wykonany zgodnie ze specyfikacją. Jedyna różnica: pominięcie zbędnego indeksu na allegro_order_status_mappings (UNIQUE KEY już istniał). + +## Issues Encountered + +None + +## Verification Results + +``` +✓ php -l src/Modules/Orders/OrdersRepository.php — No syntax errors +✓ grep correlated subqueries — 0 matches (eliminated) +✓ grep supportsMappedMedia — static + self:: confirmed +✓ Migration 000048 — exists, non-empty, idempotent (IF NOT EXISTS) +``` + +## Next Phase Readiness + +**Ready:** +- OrdersRepository zoptymalizowany — lista zamówień gotowa na wzrost danych +- Plan 07-02 (SSL + cron + migration) niezależny, może być wykonany natychmiast + +**Concerns:** +- Migracja 000048 wymaga uruchomienia na środowisku docelowym + +**Blockers:** +- None + +--- +*Phase: 07-pre-expansion-fixes, Plan: 01* +*Completed: 2026-03-14* diff --git a/.paul/phases/07-pre-expansion-fixes/07-02-SUMMARY.md b/.paul/phases/07-pre-expansion-fixes/07-02-SUMMARY.md new file mode 100644 index 0000000..5d527d9 --- /dev/null +++ b/.paul/phases/07-pre-expansion-fixes/07-02-SUMMARY.md @@ -0,0 +1,132 @@ +--- +phase: 07-pre-expansion-fixes +plan: 02 +subsystem: security, infra +tags: [ssl, curl, cron-throttle, migration-dedup, app-settings] + +requires: + - phase: 06-sonarqube-quality + provides: ApiClient classes refactored +provides: + - SSL verification in all 4 ApiClient classes + - DB-backed cron web throttle (no more $_SESSION dependency) + - Deduplicated migration sequence (000014b) +affects: [07-03, 07-04, 07-05] + +tech-stack: + added: [] + patterns: [getCaBundlePath-per-client, app-settings-as-kv-store] + +key-files: + created: + - database/migrations/20260314_000049_add_cron_last_run_at_setting.sql + modified: + - src/Modules/Settings/AllegroApiClient.php + - src/Modules/Settings/AllegroOAuthClient.php + - src/Modules/Settings/ShopproApiClient.php + - src/Modules/Settings/ApaczkaApiClient.php + - src/Core/Application.php + - .env.example + - database/migrations/20260301_000014b_add_products_sku_format_setting.sql (renamed) + +key-decisions: + - "getCaBundlePath() per class — no shared trait, acceptable duplication for 4 classes" + - "ON DUPLICATE KEY UPDATE with named params :ts/:ts2 (PDO limitation — no repeated named params)" + +patterns-established: + - "SSL: CURLOPT_SSL_VERIFYPEER=true + CURLOPT_SSL_VERIFYHOST=2 + CURLOPT_CAINFO in every curl call" + - "app_settings as key-value store for cross-session state" + +duration: ~10min +started: 2026-03-14 +completed: 2026-03-14 +--- + +# Phase 7 Plan 02: SSL + Cron Throttle + Migration Dedup Summary + +**SSL verification w 4 ApiClient klasach, cron throttle przeniesiony z $_SESSION do app_settings DB, deduplikacja migracji 000014** + +## Performance + +| Metric | Value | +|--------|-------| +| Duration | ~10min | +| Started | 2026-03-14 | +| Completed | 2026-03-14 | +| Tasks | 3 completed | +| Files modified | 8 (6 modified, 1 created, 1 renamed) | + +## Acceptance Criteria Results + +| Criterion | Status | Notes | +|-----------|--------|-------| +| AC-1: SSL weryfikowany w każdym cURL wywołaniu | Pass | 6 curl_setopt_array blocks w 4 plikach mają CURLOPT_SSL_VERIFYPEER=true + VERIFYHOST=2 + CAINFO | +| AC-2: Web cron throttle oparty na DB | Pass | $_SESSION['cron_web_last_run_at'] usunięty; getWebCronLastRunAt()/setWebCronLastRunAt() czytają/piszą app_settings | +| AC-3: Migracja 000014 zdeduplikowana | Pass | git mv → 000014b; INSERT ON DUPLICATE KEY UPDATE (idempotentna) | + +## Accomplishments + +- 4 klasy ApiClient (6 miejsc cURL) zabezpieczone SSL verification z fallback chain: ENV → XAMPP → system +- Cron web throttle nie zależy od sesji — działa poprawnie przy wielu aktywnych sesjach +- Sekwencja migracji czysta — brak duplikatów numerów + +## Files Created/Modified + +| File | Change | Purpose | +|------|--------|---------| +| `src/Modules/Settings/AllegroApiClient.php` | Modified | SSL opts w postJson(), postBinary(), requestJson() + getCaBundlePath() | +| `src/Modules/Settings/AllegroOAuthClient.php` | Modified | SSL opts w requestToken() + getCaBundlePath() | +| `src/Modules/Settings/ShopproApiClient.php` | Modified | SSL opts w requestJson() + getCaBundlePath() | +| `src/Modules/Settings/ApaczkaApiClient.php` | Modified | SSL opts w executeRequest() + getCaBundlePath() | +| `src/Core/Application.php` | Modified | isWebCronThrottled() → app_settings; +getWebCronLastRunAt(), +setWebCronLastRunAt() | +| `.env.example` | Modified | Dodano CURL_CA_BUNDLE_PATH | +| `database/migrations/20260314_000049_add_cron_last_run_at_setting.sql` | Created | Seed cron_web_last_run_at w app_settings | +| `database/migrations/20260301_000014b_...` | Renamed | git mv z 000014 na 000014b | + +## Decisions Made + +| Decision | Rationale | Impact | +|----------|-----------|--------| +| getCaBundlePath() w każdej klasie osobno | Brak wspólnego traita/klasy bazowej; 4 kopie to akceptowalna duplikacja vs. przedwczesna abstrakcja | Przyszła refaktoryzacja może wydzielić trait | +| PDO named params :ts/:ts2 w ON DUPLICATE KEY | PDO nie pozwala na powtórzenie tego samego named param; użyto dwóch nazw | Standardowy workaround | + +## Deviations from Plan + +### Summary + +| Type | Count | Impact | +|------|-------|--------| +| Auto-fixed | 1 | Minimal | +| Scope additions | 0 | - | +| Deferred | 0 | - | + +**Total impact:** Minimalna odchyłka — data pliku migracji zmieniona z 20260313 na 20260314. + +### Auto-fixed Issues + +**1. Data migracji** +- **Found during:** Task 2 +- **Issue:** Plan proponował `20260313_000049`, ale wykonanie było 2026-03-14 +- **Fix:** Użyto `20260314_000049` zgodnie z datą wykonania +- **Verification:** Plik istnieje, spójna konwencja nazewnicza + +## Issues Encountered + +None + +## Next Phase Readiness + +**Ready:** +- Wszystkie ApiClienty bezpieczne pod SSL — nowe integracje mogą kopiować wzorzec +- Cron throttle stabilny dla wielu sesji +- Plan 07-03 (UX fixes) niezależny, może być wykonany natychmiast + +**Concerns:** +- Migracja 000049 wymaga uruchomienia na środowisku docelowym + +**Blockers:** +- None + +--- +*Phase: 07-pre-expansion-fixes, Plan: 02* +*Completed: 2026-03-14* diff --git a/.paul/phases/07-pre-expansion-fixes/07-03-SUMMARY.md b/.paul/phases/07-pre-expansion-fixes/07-03-SUMMARY.md new file mode 100644 index 0000000..d177f3b --- /dev/null +++ b/.paul/phases/07-pre-expansion-fixes/07-03-SUMMARY.md @@ -0,0 +1,145 @@ +--- +phase: 07-pre-expansion-fixes +plan: 03 +subsystem: ui, security +tags: [ux, status-colors, ssl-hotfix, delivery-mapping-bug] + +requires: + - phase: 07-pre-expansion-fixes + provides: Plan 07-02 SSL verification (hotfixed here) +provides: + - orderpro_to_allegro sync disabled (ok:false + UI disabled) + - Orders list: source before ID, "ID:" prefix, sourceLabel() + - Status badges colored by group color_hex from config + - Darker form borders (#e2e8f0 → #b0bec5) + - SSL getCaBundlePath() hotfix — nullable, CAINFO only when file exists +affects: [07-04, 07-05] + +tech-stack: + added: [] + patterns: [statusColorMap, sourceLabel, conditional-curlopt-cainfo] + +key-files: + modified: + - src/Modules/Settings/AllegroStatusSyncService.php + - resources/views/settings/allegro.php + - src/Modules/Orders/OrdersController.php + - resources/scss/shared/_ui-components.scss + - public/assets/css/app.css + - src/Modules/Settings/AllegroApiClient.php + - src/Modules/Settings/AllegroOAuthClient.php + - src/Modules/Settings/ShopproApiClient.php + - src/Modules/Settings/ApaczkaApiClient.php + +key-decisions: + - "sourceLabel() z match expression — mapuje shoppro→shopPRO, allegro→Allegro, erli→Erli" + - "statusColorMap() z group color_hex — status badge inline style gdy kolor dostępny, fallback na CSS klasy" + - "SSL CURLOPT_CAINFO warunkowy — null gdy żaden CA bundle nie znaleziony, cURL używa systemowego" + +patterns-established: + - "Status coloring: statusColorMap() + inline style background-color + color:#fff" + - "SSL: getCaBundlePath() nullable + withSslOptions() helper (AllegroApiClient)" + +duration: ~20min +started: 2026-03-14 +completed: 2026-03-14 +--- + +# Phase 7 Plan 03: UX Fixes Summary + +**Disable orderpro_to_allegro sync, source/ID swap w liście zamówień, kolorowanie statusów, ciemniejsze bordery + hotfix SSL CA bundle** + +## Performance + +| Metric | Value | +|--------|-------| +| Duration | ~20min | +| Started | 2026-03-14 | +| Completed | 2026-03-14 | +| Tasks | 3 completed (+ checkpoint) | +| Files modified | 10 | + +## Acceptance Criteria Results + +| Criterion | Status | Notes | +|-----------|--------|-------| +| AC-1: orderpro_to_allegro nie daje false-positive | Pass | `ok: false` w early return | +| AC-2: Opcja UI disabled | Pass | `disabled` + "(wkrótce)" na option | +| AC-3: Source przed ID + prefix "ID:" | Pass | sourceLabel() + swap kolejności spanów | +| AC-4: Statusy kolorowane | Pass | statusColorMap() → inline style background-color | +| AC-5: Ciemniejsze obramowanie | Pass | --c-border: #e2e8f0 → #b0bec5, CSS rebuilt | + +## Accomplishments + +- orderpro_to_allegro sync zwraca ok:false (nie ok:true), opcja UI disabled z "(wkrótce)" +- Lista zamówień: source (Allegro/shopPRO/Erli) przed ID z prefixem "ID:" +- Statusy kolorowane kolorem grupy z konfiguracji, fallback na CSS klasy dla niezamapowanych +- Ciemniejsze obramowanie formularzy w całej aplikacji +- **Hotfix SSL:** getCaBundlePath() zwraca null gdy żaden CA bundle nie znaleziony — cURL używa systemowego domyślnego, eliminuje błąd na serwerze + +## Files Created/Modified + +| File | Change | Purpose | +|------|--------|---------| +| `src/Modules/Settings/AllegroStatusSyncService.php` | Modified | ok:true → ok:false dla orderpro_to_allegro | +| `resources/views/settings/allegro.php` | Modified | disabled + "(wkrótce)" na opcji sync direction | +| `src/Modules/Orders/OrdersController.php` | Modified | sourceLabel(), statusColorMap(), statusBadge() z kolorem, swap source/ID | +| `resources/scss/shared/_ui-components.scss` | Modified | --c-border: #b0bec5 | +| `public/assets/css/app.css` | Rebuilt | CSS z nowym border color | +| `src/Modules/Settings/AllegroApiClient.php` | Modified | getCaBundlePath() nullable + withSslOptions() | +| `src/Modules/Settings/AllegroOAuthClient.php` | Modified | getCaBundlePath() nullable, warunkowy CAINFO | +| `src/Modules/Settings/ShopproApiClient.php` | Modified | getCaBundlePath() nullable, warunkowy CAINFO | +| `src/Modules/Settings/ApaczkaApiClient.php` | Modified | getCaBundlePath() nullable, warunkowy CAINFO | + +## Decisions Made + +| Decision | Rationale | Impact | +|----------|-----------|--------| +| sourceLabel() match expression | Czytelne mapowanie 3 znanych źródeł + ucfirst fallback | Łatwe do rozszerzenia o nowe źródła | +| Status color inline style | group color_hex z konfiguracji; brak kolumny color w wierszu zamówienia | Nie wymaga migracji, kolor pochodzi z istniejącej tabeli order_status_groups | +| SSL CAINFO warunkowy | Serwer produkcyjny nie ma lokalnych CA bundle paths, cURL ma wbudowany systemowy | Eliminuje błąd "error setting certificate verify locations" | + +## Deviations from Plan + +### Summary + +| Type | Count | Impact | +|------|-------|--------| +| Auto-fixed | 1 | Critical — SSL hotfix z planu 07-02 | +| Scope additions | 0 | - | +| Deferred | 1 | Pre-existing bug | + +### Auto-fixed Issues + +**1. SSL CA bundle path error na serwerze** +- **Found during:** Checkpoint verification (user report) +- **Issue:** getCaBundlePath() zwracał hardcoded path który nie istnieje na serwerze → cURL error +- **Fix:** getCaBundlePath() zwraca null gdy brak pliku; CURLOPT_CAINFO ustawiany warunkowo +- **Files:** 4 ApiClient klasy +- **Verification:** User potwierdzi po deploy + +### Deferred Items + +- **Pre-existing:** Pole "Szukaj..." w mapowaniu form dostawy Allegro — JS `attachSelectFilter()` tworzy input search dla InPost/Apaczka selectów, layout myląco wygląda jakby należał do wiersza powyżej + +## Issues Encountered + +| Issue | Resolution | +|-------|------------| +| SSL error na serwerze produkcyjnym | getCaBundlePath() → nullable, CAINFO warunkowy | + +## Next Phase Readiness + +**Ready:** +- UX fixes wdrożone, lista zamówień czytelniejsza +- Plan 07-04 (testy) niezależny + +**Concerns:** +- Delivery mapping "Szukaj..." layout — pre-existing, do naprawy osobno + +**Blockers:** +- None + +--- +*Phase: 07-pre-expansion-fixes, Plan: 03* +*Completed: 2026-03-14* diff --git a/.vscode/ftp-kr.sync.cache.json b/.vscode/ftp-kr.sync.cache.json index 8a769b9..482c77a 100644 --- a/.vscode/ftp-kr.sync.cache.json +++ b/.vscode/ftp-kr.sync.cache.json @@ -730,32 +730,32 @@ }, "20260308_000038_ensure_order_status_mappings_table.sql": { "type": "-", - "size": 888, - "lmtime": 1772991139910, + "size": 1513, + "lmtime": 1773391946879, "modified": false }, "20260308_000039_ensure_integrations_fetch_columns.sql": { "type": "-", - "size": 808, - "lmtime": 1772991829022, + "size": 1596, + "lmtime": 1773391955510, "modified": false }, "20260308_000040_ensure_shoppro_orders_import_schedule.sql": { "type": "-", - "size": 527, - "lmtime": 1772992935478, + "size": 1251, + "lmtime": 1773391962537, "modified": false }, "20260308_000041_ensure_shoppro_status_sync_schedule_and_direction.sql": { "type": "-", - "size": 989, - "lmtime": 1772993839010, + "size": 1780, + "lmtime": 1773391969582, "modified": false }, "20260308_000042_ensure_shoppro_payment_sync_schedule_and_columns.sql": { "type": "-", - "size": 958, - "lmtime": 1772997818616, + "size": 1660, + "lmtime": 1773391978785, "modified": false }, "20260308_000043_create_shoppro_delivery_method_mappings_table.sql": { @@ -832,14 +832,14 @@ "DOCS": { "ARCHITECTURE.md": { "type": "-", - "size": 28217, + "size": 28598, "lmtime": 1773009533084, - "modified": false + "modified": true }, "DB_SCHEMA.md": { "type": "-", - "size": 20060, - "lmtime": 1773009570461, + "size": 21229, + "lmtime": 1773392001064, "modified": false }, "ORDERS_SCHEMA_APILO_DRAFT.md": { @@ -862,9 +862,9 @@ }, "todo.md": { "type": "-", - "size": 1677, + "size": 3163, "lmtime": 1772997209432, - "modified": false + "modified": true } }, ".env": { @@ -2397,8 +2397,8 @@ "routes": { "web.php": { "type": "-", - "size": 13708, - "lmtime": 1773000073865, + "size": 14437, + "lmtime": 1773418817257, "modified": false } }, @@ -2453,9 +2453,9 @@ "Core": { "Application.php": { "type": "-", - "size": 13242, + "size": 8679, "lmtime": 1772997978216, - "modified": false + "modified": true }, "Database": { "ConnectionFactory.php": { @@ -2471,6 +2471,44 @@ "modified": false } }, + "Exceptions": { + "AllegroApiException.php": { + "type": "-", + "size": 128, + "lmtime": 1773396173463, + "modified": false + }, + "AllegroOAuthException.php": { + "type": "-", + "size": 132, + "lmtime": 1773396174308, + "modified": false + }, + "ApaczkaApiException.php": { + "type": "-", + "size": 128, + "lmtime": 1773396175168, + "modified": false + }, + "OrderProException.php": { + "type": "-", + "size": 126, + "lmtime": 1773396172716, + "modified": false + }, + "ShipmentException.php": { + "type": "-", + "size": 126, + "lmtime": 1773396176089, + "modified": false + }, + "IntegrationConfigException.php": { + "type": "-", + "size": 135, + "lmtime": 1773396176697, + "modified": false + } + }, "Http": { "Request.php": { "type": "-", @@ -2542,6 +2580,20 @@ "lmtime": 1771460443424, "modified": false } + }, + "Constants": { + "IntegrationSources.php": { + "type": "-", + "size": 263, + "lmtime": 1773397435140, + "modified": false + }, + "RedirectPaths.php": { + "type": "-", + "size": 643, + "lmtime": 1773397436702, + "modified": false + } } }, "Modules": { @@ -2584,6 +2636,12 @@ "lmtime": 1772655083686, "modified": false }, + "CronHandlerFactory.php": { + "type": "-", + "size": 4482, + "lmtime": 1773418273174, + "modified": false + }, "CronJobProcessor.php": { "type": "-", "size": 6385, @@ -2644,17 +2702,17 @@ "lmtime": 1772489139382, "modified": false }, - "ShopproStatusSyncHandler.php": { - "type": "-", - "size": 456, - "lmtime": 1772993806375, - "modified": false - }, "ShopproPaymentStatusSyncHandler.php": { "type": "-", "size": 577, "lmtime": 1772997967988, "modified": false + }, + "ShopproStatusSyncHandler.php": { + "type": "-", + "size": 456, + "lmtime": 1772993806375, + "modified": false } }, "GS1": { @@ -2700,15 +2758,15 @@ }, "OrdersController.php": { "type": "-", - "size": 27279, - "lmtime": 1773359707641, + "size": 27184, + "lmtime": 1773392942464, "modified": false }, "OrdersRepository.php": { "type": "-", - "size": 30415, - "lmtime": 1772996631841, - "modified": true + "size": 31275, + "lmtime": 1773401508499, + "modified": false }, "OrderStatusSyncService.php": { "type": "-", @@ -2796,8 +2854,14 @@ "Settings": { "AllegroApiClient.php": { "type": "-", - "size": 11015, - "lmtime": 1772746725988, + "size": 11092, + "lmtime": 1773396192542, + "modified": false + }, + "AllegroDeliveryMappingController.php": { + "type": "-", + "size": 9202, + "lmtime": 1773418766854, "modified": false }, "AllegroDeliveryMethodMappingRepository.php": { @@ -2808,62 +2872,74 @@ }, "AllegroIntegrationController.php": { "type": "-", - "size": 39516, - "lmtime": 1772999795856, + "size": 26499, + "lmtime": 1773418757646, "modified": false }, "AllegroIntegrationRepository.php": { "type": "-", - "size": 15433, - "lmtime": 1772985465032, + "size": 15397, + "lmtime": 1773396209969, "modified": false }, "AllegroOAuthClient.php": { "type": "-", - "size": 6130, - "lmtime": 1772746387780, + "size": 6185, + "lmtime": 1773396209523, "modified": false }, "AllegroOrderImportService.php": { "type": "-", - "size": 32847, - "lmtime": 1772985483883, + "size": 29620, + "lmtime": 1773397494713, "modified": false }, "AllegroOrdersSyncService.php": { "type": "-", - "size": 11653, - "lmtime": 1772985474211, + "size": 8112, + "lmtime": 1773396210179, "modified": false }, "AllegroOrderSyncStateRepository.php": { "type": "-", - "size": 8757, + "size": 8672, "lmtime": 1772660685699, - "modified": false + "modified": true }, "AllegroStatusDiscoveryService.php": { "type": "-", - "size": 5786, + "size": 2545, "lmtime": 1772657848652, + "modified": true + }, + "AllegroStatusMappingController.php": { + "type": "-", + "size": 7597, + "lmtime": 1773418641524, "modified": false }, "AllegroStatusMappingRepository.php": { "type": "-", - "size": 4690, + "size": 4584, "lmtime": 1772657817169, - "modified": false + "modified": true }, "AllegroStatusSyncService.php": { "type": "-", - "size": 3266, - "lmtime": 1772803803020, + "size": 3869, + "lmtime": 1773397499705, + "modified": false + }, + "AllegroTokenManager.php": { + "type": "-", + "size": 3075, + "lmtime": 1773396209752, "modified": false }, "ApaczkaApiClient.php": { "type": "-", - "size": 8911, - "lmtime": 1773001526890, + "size": 8957, + "lmtime": 1773396223690, "modified": false }, "ApaczkaIntegrationController.php": { @@ -2874,8 +2950,8 @@ }, "ApaczkaIntegrationRepository.php": { "type": "-", - "size": 6203, - "lmtime": 1773001299233, + "size": 6268, + "lmtime": 1773396223901, "modified": false }, "AppSettingsRepository.php": { @@ -2886,9 +2962,9 @@ }, "CarrierDeliveryMethodMappingRepository.php": { "type": "-", - "size": 8057, + "size": 7901, "lmtime": 1773004976663, - "modified": false + "modified": true }, "CompanySettingsController.php": { "type": "-", @@ -2898,9 +2974,9 @@ }, "CompanySettingsRepository.php": { "type": "-", - "size": 7232, + "size": 7207, "lmtime": 1773009466748, - "modified": false + "modified": true }, "CronSettingsController.php": { "type": "-", @@ -2916,8 +2992,8 @@ }, "InpostIntegrationRepository.php": { "type": "-", - "size": 8562, - "lmtime": 1772985383681, + "size": 8498, + "lmtime": 1773396224300, "modified": false }, "IntegrationRepository.php": { @@ -2928,8 +3004,8 @@ }, "IntegrationSecretCipher.php": { "type": "-", - "size": 2085, - "lmtime": 1772985270857, + "size": 2140, + "lmtime": 1773396224100, "modified": false }, "IntegrationsHubController.php": { @@ -2940,9 +3016,9 @@ }, "IntegrationsRepository.php": { "type": "-", - "size": 4220, + "size": 4106, "lmtime": 1772985295068, - "modified": false + "modified": true }, "OrderStatusMappingRepository.php": { "type": "-", @@ -2958,9 +3034,9 @@ }, "SettingsController.php": { "type": "-", - "size": 16872, + "size": 16670, "lmtime": 1772493293023, - "modified": false + "modified": true }, "ShopproApiClient.php": { "type": "-", @@ -2982,26 +3058,44 @@ }, "ShopproIntegrationsController.php": { "type": "-", - "size": 36438, - "lmtime": 1773003944439, + "size": 37809, + "lmtime": 1773408010714, "modified": false }, "ShopproIntegrationsRepository.php": { "type": "-", - "size": 21407, - "lmtime": 1772997877315, + "size": 21350, + "lmtime": 1773396224535, + "modified": false + }, + "ShopproOrderMapper.php": { + "type": "-", + "size": 38431, + "lmtime": 1773418140037, "modified": false }, "ShopproOrdersSyncService.php": { "type": "-", - "size": 52376, - "lmtime": 1773005399696, + "size": 12433, + "lmtime": 1773418261049, + "modified": false + }, + "ShopproOrderSyncStateRepository.php": { + "type": "-", + "size": 8941, + "lmtime": 0, "modified": false }, "ShopproPaymentStatusSyncService.php": { "type": "-", - "size": 14133, - "lmtime": 1772997958340, + "size": 13746, + "lmtime": 1773397561728, + "modified": false + }, + "ShopproProductImageResolver.php": { + "type": "-", + "size": 4673, + "lmtime": 1773418240242, "modified": false }, "ShopproStatusMappingRepository.php": { @@ -3013,7 +3107,7 @@ "ShopproStatusSyncService.php": { "type": "-", "size": 2075, - "lmtime": 1772993798910, + "lmtime": 1773397552472, "modified": false } }, @@ -3034,20 +3128,20 @@ "Shipments": { "AllegroShipmentService.php": { "type": "-", - "size": 15178, - "lmtime": 1772999590482, - "modified": true + "size": 14926, + "lmtime": 1773396238404, + "modified": false }, "ApaczkaShipmentService.php": { "type": "-", - "size": 32921, - "lmtime": 1773009754471, + "size": 33037, + "lmtime": 1773396238099, "modified": false }, "ShipmentController.php": { "type": "-", - "size": 16795, - "lmtime": 1773359710858, + "size": 16702, + "lmtime": 1773396224755, "modified": false }, "ShipmentPackageRepository.php": { diff --git a/DOCS/DB_SCHEMA.md b/DOCS/DB_SCHEMA.md index 7efb29e..2af1a73 100644 --- a/DOCS/DB_SCHEMA.md +++ b/DOCS/DB_SCHEMA.md @@ -85,6 +85,9 @@ Migracje z prefiksem `ensure_` to migracje kompensujące — zostały dodane - rozszerzenie `company_settings` o `sender_contact_person` (osoba kontaktowa nadawcy), - wykorzystywane w payloadzie Apaczka jako `address.sender.contact_person`. - 2026-03-08: Ujednolicono styl naglowkow sekcji UI (`section-title`) - bez zmian schematu bazy. +- 2026-03-14: Dodano migracje `20260314_000048_add_orders_performance_indexes.sql` — indeksy wydajnosciowe na tabeli `orders`: `source`, `external_status_id`, `ordered_at`, composite `(source, external_status_id)`. +- 2026-03-14: Dodano migracje `20260314_000049_add_cron_last_run_at_setting.sql` — seed klucza `cron_web_last_run_at` w `app_settings` (cron throttle przeniesiony z sesji do DB). +- 2026-03-14: Przemianowano migracje `20260301_000014_add_products_sku_format_setting.sql` na `20260301_000014b_add_products_sku_format_setting.sql` — deduplikacja numeru sekwencji (kolizja z `000014_create_product_integration_translations`). ## Tabele diff --git a/DOCS/TECH_CHANGELOG.md b/DOCS/TECH_CHANGELOG.md index 1cb78ea..d6cd059 100644 --- a/DOCS/TECH_CHANGELOG.md +++ b/DOCS/TECH_CHANGELOG.md @@ -1,5 +1,19 @@ # Tech Changelog +## 2026-03-14 +- Zoptymalizowano zapytanie listy zamowien (`OrdersRepository::buildListSql()`): + - 4 correlated subqueries (items_count, items_qty, shipments_count, documents_count) zastapiono aggregating LEFT JOINami — eliminuje N+1 na kazdym wierszu listy. +- `OrdersRepository::canResolveMappedMedia()` — zamiana instance property na `static` — `information_schema` odpytywany co najwyzej raz na cykl PHP zamiast raz per instancja. +- Dodano migracje `20260314_000048_add_orders_performance_indexes.sql` — indeksy na `orders`: `source`, `external_status_id`, `ordered_at`, composite `(source, external_status_id)`. +- Dodano SSL verification (`CURLOPT_SSL_VERIFYPEER => true`, `CURLOPT_SSL_VERIFYHOST => 2`, `CURLOPT_CAINFO`) do 4 klas ApiClient: AllegroApiClient (3 metody), AllegroOAuthClient, ShopproApiClient, ApaczkaApiClient. Fallback: `$_ENV['CURL_CA_BUNDLE_PATH']` → XAMPP cacert.pem → system CA bundle. +- Cron web throttle (`isWebCronThrottled()`) przeniesiony z `$_SESSION` do `app_settings` (klucz `cron_web_last_run_at`) — eliminuje wielokrotne uruchamianie crona przy wielu aktywnych sesjach. +- Deduplikacja migracji `000014` → `000014b` (kolizja z `create_product_integration_translations`). +- `AllegroStatusSyncService::sync()` zwraca `ok:false` dla kierunku `orderpro_to_allegro` (wczesniej false-positive `ok:true`). Opcja UI oznaczona jako `disabled` z `(wkrotce)`. +- Lista zamowien: source wyswietlany przed ID z prefixem `ID:`; `sourceLabel()` mapuje shoppro→shopPRO, allegro→Allegro. +- Statusy zamowien na liscie kolorowane kolorem grupy z konfiguracji (`statusColorMap()` → inline `background-color`). +- Ciemniejsze obramowanie pol formularzy: `--c-border` zmieniony z `#e2e8f0` na `#b0bec5`. +- Hotfix SSL: `getCaBundlePath()` zwraca `null` gdy zaden CA bundle nie znaleziony — `CURLOPT_CAINFO` ustawiany warunkowo, cURL uzywa systemowego CA na serwerze. + ## 2026-03-08 - Poprawiono date podjazdu kuriera w payloadzie Apaczka: - `pickup.date` dla trybu `COURIER` jest normalizowane tak, aby nie wypadal w niedziele (niedziela -> poniedzialek), diff --git a/database/migrations/20260314_000048_add_orders_performance_indexes.sql b/database/migrations/20260314_000048_add_orders_performance_indexes.sql new file mode 100644 index 0000000..3d0be30 --- /dev/null +++ b/database/migrations/20260314_000048_add_orders_performance_indexes.sql @@ -0,0 +1,8 @@ +-- Performance indexes for orders list view +-- Covers: source filter, external_status_id filter/sort, ordered_at sort, +-- composite source+external_status_id for combined filtering +ALTER TABLE orders + ADD INDEX IF NOT EXISTS orders_source_idx (source), + ADD INDEX IF NOT EXISTS orders_external_status_idx (external_status_id), + ADD INDEX IF NOT EXISTS orders_ordered_at_idx (ordered_at), + ADD INDEX IF NOT EXISTS orders_source_status_idx (source, external_status_id); diff --git a/database/migrations/20260314_000049_add_cron_last_run_at_setting.sql b/database/migrations/20260314_000049_add_cron_last_run_at_setting.sql new file mode 100644 index 0000000..60e3271 --- /dev/null +++ b/database/migrations/20260314_000049_add_cron_last_run_at_setting.sql @@ -0,0 +1,3 @@ +INSERT INTO app_settings (setting_key, setting_value, created_at, updated_at) +VALUES ('cron_web_last_run_at', '0', NOW(), NOW()) +ON DUPLICATE KEY UPDATE updated_at = updated_at; diff --git a/public/assets/css/app.css b/public/assets/css/app.css index e600190..ccdc57d 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -1 +1 @@ -:root{--c-primary: #6690f4;--c-primary-dark: #3164db;--c-bg: #f4f6f9;--c-surface: #ffffff;--c-text: #4e5e6a;--c-text-strong: #2d3748;--c-muted: #718096;--c-border: #e2e8f0;--c-danger: #cc0000;--focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15);--shadow-card: 0 1px 4px rgba(0, 0, 0, 0.06)}.btn{display:inline-flex;align-items:center;justify-content:center;min-height:34px;padding:6px 12px;border:1px solid rgba(0,0,0,0);border-radius:8px;font:inherit;font-weight:600;text-decoration:none;cursor:pointer;transition:background-color .2s ease,border-color .2s ease,color .2s ease,transform .1s ease}.btn--primary{color:#fff;background:var(--c-primary)}.btn--primary:hover{background:var(--c-primary-dark)}.btn--secondary{color:var(--c-text-strong);border-color:var(--c-border);background:var(--c-surface)}.btn--secondary:hover{border-color:#cbd5e0;background:#f8fafc}.btn--danger{color:#fff;border-color:#b91c1c;background:#dc2626}.btn--danger:hover{border-color:#991b1b;background:#b91c1c}.btn--sm{min-height:28px;padding:3px 10px;font-size:12px}.btn--block{width:100%}.btn:active{transform:translateY(1px)}.btn:focus-visible{outline:none;box-shadow:var(--focus-ring);border-color:var(--c-primary)}.form-control{width:100%;min-height:34px;border:1px solid var(--c-border);border-radius:8px;padding:5px 10px;font:inherit;color:var(--c-text-strong);background:#fff;transition:border-color .2s ease,box-shadow .2s ease}.form-control:focus{outline:none;border-color:var(--c-primary);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:#fff}.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;border:1px solid rgba(0,0,0,0);font-size:13px;min-height:44px}.alert--danger{border-color:#fed7d7;background:#fff5f5;color:var(--c-danger)}.alert--success{border-color:#b7ebcf;background:#f0fff6;color:#0f6b39}.alert--warning{border-color:#f7dd8b;background:#fff8e8;color:#815500}.form-field{display:grid;gap:5px}.field-label{color:var(--c-text-strong);font-size:13px;font-weight:600}.table-wrap{width:100%;overflow-x:auto}.table-wrap--visible{overflow:visible !important;overflow-x:visible !important}.table{width:100%;border-collapse:collapse;background:var(--c-surface)}.table th,.table td{padding:10px 12px;border-bottom:1px solid var(--c-border);text-align:left}.table th{color:var(--c-text-strong);font-weight:700;background:#f8fafc}.table--details th{white-space:nowrap}.table--details th:first-child,.table--details td:first-child{width:36px;text-align:center}.pagination{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.pagination__item{display:inline-flex;align-items:center;justify-content:center;min-width:36px;height:36px;padding:0 10px;border-radius:8px;border:1px solid var(--c-border);color:var(--c-text-strong);background:var(--c-surface);text-decoration:none;font-weight:600}.pagination__item:hover{border-color:#cbd5e0;background:#f8fafc}.pagination__item.is-active{border-color:var(--c-primary);color:var(--c-primary);background:#edf2ff}*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;font-family:"Roboto","Segoe UI",sans-serif;font-size:13px;color:var(--c-text);background:var(--c-bg)}a{color:var(--c-primary)}.app-shell{min-height:100vh;display:flex}.sidebar{width:260px;min-width:260px;flex-shrink:0;overflow:hidden;transition:width .22s ease,min-width .22s ease;border-right:1px solid #243041;background:#111a28;padding:18px 10px;display:flex;flex-direction:column}.sidebar.is-collapsed{width:52px;min-width:52px}.sidebar__brand{display:flex;align-items:center;justify-content:space-between;margin:4px 4px 16px;gap:6px;min-width:0}.sidebar__brand-text{color:#e9f0ff;font-size:24px;font-weight:300;letter-spacing:-0.02em;white-space:nowrap;overflow:hidden;flex:1;min-width:0}.sidebar__brand-text strong{font-weight:700}.sidebar__collapse-btn{flex-shrink:0;width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0);border:1px solid #2a3a54;border-radius:6px;color:#64748b;cursor:pointer;padding:0;transition:background .15s,color .15s}.sidebar__collapse-btn:hover{background:#1b2a3f;color:#cbd5e1}.sidebar__collapse-icon{display:block;transition:transform .22s ease;flex-shrink:0}.sidebar.is-collapsed .sidebar__collapse-icon{transform:rotate(180deg)}.sidebar__nav{display:grid;gap:4px}.sidebar__link{border-radius:8px;padding:10px 12px;text-decoration:none;color:#cbd5e1;font-weight:600}.sidebar__link:hover{color:#f8fafc;background:#1b2a3f}.sidebar__link.is-active{color:#fff;background:#2e4f93}.sidebar__group{display:grid;gap:2px}.sidebar__group-toggle{list-style:none;border-radius:8px;padding:9px 10px;color:#cbd5e1;font-weight:600;cursor:pointer;display:flex;align-items:center;gap:9px;white-space:nowrap;user-select:none}.sidebar__group-toggle::-webkit-details-marker{display:none}.sidebar__group:hover .sidebar__group-toggle,.sidebar__group-toggle:hover{color:#f8fafc;background:#1b2a3f}.sidebar__group.is-active .sidebar__group-toggle{color:#fff;background:#2e4f93}.sidebar__icon{flex-shrink:0;width:18px;height:18px;display:flex;align-items:center;justify-content:center;opacity:.85}.sidebar__label{flex:1;min-width:0;overflow:hidden}.sidebar__toggle-arrow{flex-shrink:0;margin-left:auto;opacity:.5;transition:transform .18s ease}details[open]>.sidebar__group-toggle .sidebar__toggle-arrow{transform:rotate(180deg)}.sidebar__group-links{display:grid;gap:2px;padding-left:12px;overflow:hidden}.sidebar__sublink{border-radius:6px;padding:7px 10px 7px 8px;text-decoration:none;color:#94a3b8;font-size:12.5px;font-weight:500;display:flex;align-items:center;gap:8px;white-space:nowrap}.sidebar__sublink::before{content:"";flex-shrink:0;width:5px;height:5px;border-radius:50%;background:rgba(148,163,184,.3);transition:background .15s}.sidebar__sublink:hover{color:#e2e8f0;background:#1b2a3f}.sidebar__sublink:hover::before{background:rgba(148,163,184,.65)}.sidebar__sublink.is-active{color:#fff;background:rgba(46,79,147,.55)}.sidebar__sublink.is-active::before{background:#93c5fd}.app-main{flex:1;min-width:0}.topbar{height:50px;border-bottom:1px solid var(--c-border);background:var(--c-surface);display:flex;align-items:center;justify-content:space-between;padding:0 20px;position:sticky;top:0;z-index:100}.brand{font-size:22px;font-weight:300;letter-spacing:-0.02em;color:var(--c-text-strong)}.brand strong{font-weight:700}.container{max-width:none;width:calc(100% - 20px);margin:12px 10px;padding:0 4px 14px}.card{background:var(--c-surface);border-radius:10px;box-shadow:var(--shadow-card);padding:14px}.card h1{margin:0 0 10px;color:var(--c-text-strong);font-size:24px;font-weight:700}.muted{color:var(--c-muted)}.accent{color:var(--c-primary);font-weight:600}.users-form{display:grid;gap:14px;max-width:460px}.section-title{margin:0;color:var(--c-text-strong);font-size:18px;font-weight:700}h2.section-title,h3.section-title,h4.section-title{display:flex;align-items:center;gap:8px;font-weight:400;padding:5px 10px;border-left:3px solid var(--c-primary);border-radius:7px;background:linear-gradient(180deg, #f4f8ff 0%, #edf3ff 100%);color:#1e3a8a;box-shadow:inset 0 0 0 1px #dbe7fb}.mt-0{margin-top:0}.mt-4{margin-top:4px}.mt-12{margin-top:8px}.mt-16{margin-top:12px}.settings-grid{display:grid;grid-template-columns:repeat(3, minmax(0, 1fr));gap:12px}.settings-nav{display:flex;gap:8px;flex-wrap:wrap}.settings-nav__link{text-decoration:none;border:1px solid var(--c-border);border-radius:8px;padding:8px 12px;color:var(--c-text-strong);font-weight:600}.settings-nav__link:hover{background:#f8fafc}.settings-nav__link.is-active{border-color:var(--c-primary);color:var(--c-primary);background:#edf2ff}.settings-stat{border:1px solid var(--c-border);border-radius:8px;padding:12px;background:#f8fafc}.settings-stat__label{display:block;color:var(--c-muted);font-size:12px;margin-bottom:4px}.settings-stat__value{color:var(--c-text-strong);font-size:20px}.settings-logs{margin:0;padding:12px;border-radius:8px;border:1px solid var(--c-border);background:#0b1220;color:#d1d5db;font-size:12px;line-height:1.5;overflow:auto}.settings-allegro-callback{display:block;width:100%;padding:8px 10px;border:1px solid var(--c-border);border-radius:8px;background:#f8fafc;color:var(--c-text-strong);font-size:12px;line-height:1.45;word-break:break-all}.page-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.filters-grid{display:grid;grid-template-columns:repeat(3, minmax(0, 1fr));gap:12px}.filters-actions{display:flex;align-items:end;gap:8px}.product-form .form-control{width:100%}.form-grid{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));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;flex-wrap:wrap;align-items:flex-start}.form-actions .btn{align-self:flex-start}.statuses-form{display:grid;gap:8px;grid-template-columns:repeat(2, minmax(0, 1fr))}.statuses-form .form-actions{grid-column:1/-1}.statuses-color-input{min-height:32px;padding:2px}.statuses-hint{grid-column:1/-1;margin:0}.statuses-group-block{border:1px solid var(--c-border);border-radius:10px;padding:8px;background:#fbfdff}.statuses-group-block__head{display:flex;align-items:center;justify-content:space-between;gap:6px;flex-wrap:wrap}.statuses-group-block__title{margin:0;display:inline-flex;align-items:center;gap:6px;color:var(--c-text-strong);font-size:14px}.statuses-color-dot{width:12px;height:12px;border-radius:999px;border:1px solid rgba(15,23,42,.15)}.statuses-dnd-list{margin:6px 0 0;padding:0;list-style:none;display:grid;gap:6px}.statuses-dnd-item{display:grid;grid-template-columns:24px 1fr;gap:6px;border:1px solid #dce4f0;border-radius:8px;background:#fff;padding:6px}.statuses-dnd-item__content{display:flex;align-items:center;gap:6px;min-width:0}.statuses-dnd-item.is-dragging{opacity:.6}.statuses-dnd-item__drag{display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;border-radius:6px;color:#64748b;cursor:grab;user-select:none;font-weight:700;font-size:12px}.statuses-dnd-item__drag:active{cursor:grabbing}.statuses-inline-form{display:grid;gap:6px}.statuses-inline-form--row{grid-template-columns:minmax(180px, 1.4fr) minmax(150px, 1fr) auto auto auto;align-items:center;flex:1 1 auto;min-width:0}.statuses-inline-form--row-group{grid-template-columns:minmax(180px, 1.5fr) 56px auto auto auto;align-items:center;flex:1 1 auto;min-width:0}.statuses-inline-form--row .form-control,.statuses-inline-form--row-group .form-control{min-height:30px;padding:4px 8px}.statuses-inline-form--row .btn,.statuses-inline-form--row-group .btn,.statuses-inline-delete .btn{min-height:30px;padding:4px 10px;font-size:12px}.statuses-inline-check{margin-top:0;white-space:nowrap;font-size:12px}.statuses-inline-delete{margin:0;flex:0 0 auto}.statuses-code-label{font-size:12px;color:var(--c-muted)}.statuses-code-readonly{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;font-size:12px}.statuses-code-readonly code{background:#eef2f7;border-radius:6px;padding:1px 6px;color:#1f2937;font-size:12px}.field-inline{display:flex;align-items:center;gap:8px;margin-top:2px}.modal-backdrop{position:fixed;inset:0;background:rgba(15,23,42,.5);display:flex;align-items:center;justify-content:center;padding:16px;z-index:200}.modal-backdrop[hidden]{display:none}.modal{width:min(560px,100%);background:#fff;border-radius:10px;box-shadow:0 20px 40px rgba(15,23,42,.35);overflow:hidden}.modal__header{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:16px 18px;border-bottom:1px solid var(--c-border)}.modal__header h3{margin:0;font-size:18px;color:var(--c-text-strong)}.modal__body{padding:16px 18px 18px}.status-pill{display:inline-flex;align-items:center;justify-content:center;border:1px solid #fed7d7;background:#fff5f5;color:#9b2c2c;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:600}.status-pill.is-active{border-color:#b7ebcf;background:#f0fff6;color:#0f6b39}.table-row-actions{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap}.table-row-actions form{margin:0}.table-list{display:grid;gap:14px}.table-list__header{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap}.table-list__left{display:inline-flex;align-items:center;gap:8px;flex-wrap:wrap}.table-list-header-actions{display:inline-flex;align-items:center;gap:10px;flex-wrap:wrap}.js-filter-toggle-btn.is-active{border-color:#cbd5e0;background:#edf2ff;color:var(--c-primary-dark)}.table-filter-badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;font-size:11px;font-weight:700;color:#fff;background:var(--c-primary);border-radius:999px}.table-filters-wrapper{display:none}.table-filters-wrapper.is-open{display:block}.table-list-filters{display:grid;gap:12px;grid-template-columns:repeat(auto-fit, minmax(170px, 1fr))}.table-col-toggle-wrapper{position:relative}.table-col-toggle-dropdown{display:none;position:absolute;right:0;top:calc(100% + 6px);z-index:30;width:260px;max-height:360px;overflow:auto;border:1px solid var(--c-border);border-radius:10px;background:#fff;box-shadow:0 10px 25px rgba(15,23,42,.12)}.table-col-toggle-dropdown.is-open{display:block}.table-col-toggle-header{padding:10px 12px;border-bottom:1px solid var(--c-border);font-size:12px;font-weight:700;color:var(--c-muted)}.table-col-toggle-item{display:flex;align-items:center;gap:10px;padding:8px 12px;font-size:13px;color:var(--c-text-strong)}.table-col-toggle-item:hover{background:#f8fafc}.table-col-toggle-footer{border-top:1px solid var(--c-border);padding:8px 12px}.table-col-hidden{display:none}.table-col-switch{position:relative;display:inline-block;width:34px;min-width:34px;height:18px}.table-col-switch input{opacity:0;width:0;height:0;position:absolute}.table-col-switch-slider{position:absolute;top:0;left:0;right:0;bottom:0;background:#cbd5e1;border-radius:999px;transition:background-color .2s ease}.table-col-switch-slider::before{content:"";position:absolute;height:14px;width:14px;left:2px;bottom:2px;background:#fff;border-radius:50%;transition:transform .2s ease}.table-col-switch input:checked+.table-col-switch-slider{background:#16a34a}.table-col-switch input:checked+.table-col-switch-slider::before{transform:translateX(16px)}.table-sort-link{display:inline-flex;align-items:center;gap:6px;color:var(--c-text-strong);text-decoration:none}.table-sort-link:hover{color:var(--c-primary-dark)}.table-sort-icon.is-muted{color:#a0aec0}.table-list__footer{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}.table-list-per-page-form{display:inline-flex;align-items:center;gap:8px}.table-list-per-page-form .form-control{min-width:90px}.table-select-col{width:44px;text-align:center}.table-select-toggle{display:inline-flex;align-items:center;justify-content:center}.table-select-toggle input[type=checkbox]{width:16px;height:16px}.orders-page .orders-head{background:linear-gradient(120deg, #f8fbff 0%, #eef5ff 100%);border:1px solid #dbe7fb}.orders-page .table-list{border:1px solid #dde5f2;border-radius:12px;box-shadow:0 6px 16px rgba(20,44,86,.08)}.orders-page .table-list__header{padding:10px 6px 2px}.orders-page .table-list-filters{padding:6px 6px 2px;border-top:1px solid #ebf0f7;border-bottom:1px solid #ebf0f7;background:#f9fbff}.orders-page .table-wrap{border-radius:10px;overflow:hidden;border:1px solid #e7edf6}.orders-page .table thead th{background:#f3f7fd;color:#30435f;font-size:12px;text-transform:uppercase;letter-spacing:.03em}.orders-page .table tbody td{vertical-align:middle;padding-top:10px;padding-bottom:10px;border-bottom-color:#edf2f8}.orders-page .table tbody tr:hover td{background:#f9fcff}.orders-list-page{padding:10px;margin-bottom:10px}.orders-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}.orders-stats{display:inline-grid;grid-template-columns:repeat(3, minmax(86px, auto));gap:8px}.orders-stat{border:1px solid #d8e2f0;background:#f8fbff;border-radius:8px;padding:6px 8px;line-height:1.15}.orders-stat__label{display:block;color:#5f6f83;font-size:11px;margin-bottom:2px}.orders-stat__value{color:#12233a;font-size:16px;font-weight:700}.orders-ref{display:grid;gap:2px;min-width:170px}.orders-ref__main{font-weight:700;color:#0f1f35;font-size:14px}.orders-ref__meta{display:inline-flex;flex-wrap:wrap;gap:4px 10px;color:#64748b;font-size:12px}.orders-buyer{display:grid;gap:2px}.orders-buyer__name{color:#0f172a;font-weight:600;font-size:14px}.orders-buyer__meta{display:inline-flex;flex-wrap:wrap;gap:4px 10px;color:#64748b;font-size:12px}.orders-status-wrap{display:inline-flex;align-items:center;gap:5px;flex-wrap:wrap}.order-tag{display:inline-flex;align-items:center;justify-content:center;border:1px solid #d8e1ef;background:#f8fafc;color:#334155;border-radius:999px;padding:2px 8px;font-size:12px;font-weight:700;line-height:1.1}.order-tag.is-info{border-color:#bfdbfe;background:#eff6ff;color:#1d4ed8}.order-tag.is-success{border-color:#bbf7d0;background:#f0fdf4;color:#166534}.order-tag.is-danger{border-color:#fecaca;background:#fef2f2;color:#b91c1c}.order-tag.is-warn{border-color:#fde68a;background:#fffbeb;color:#92400e}.order-tag.is-cod{border-color:#f9a8d4;background:#fdf2f8;color:#9d174d}.order-tag.is-unpaid{border-color:#fca5a5;background:#fef2f2;color:#b91c1c}.orders-mini{font-size:14px;color:#223247;line-height:1.25}.orders-mini__delivery{font-size:12px;color:#64748b;margin-bottom:2px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.orders-products{display:grid;gap:4px;min-width:240px}.orders-products__meta,.orders-products__more{font-size:12px;color:#64748b}.orders-product{display:grid;grid-template-columns:48px 1fr;gap:6px;align-items:center}.orders-product__thumb{width:48px;height:48px;border-radius:4px;border:1px solid #dbe3ef;object-fit:cover;background:#fff}.orders-product__thumb--empty{display:inline-block;background:#eef2f7;border-style:dashed}.orders-product__txt{min-width:0;display:grid;gap:1px}.orders-product__name{font-size:14px;color:#0f172a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.orders-product__qty{font-size:12px;color:#64748b}.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,.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}.activity-type-badge--status_change{background:#dbeafe;color:#1e40af}.activity-type-badge--payment{background:#dcfce7;color:#166534}.activity-type-badge--invoice{background:#fef3c7;color:#92400e}.activity-type-badge--shipment{background:#e0e7ff;color:#3730a3}.activity-type-badge--message{background:#f3e8ff;color:#6b21a8}.activity-type-badge--document{background:#fce7f3;color:#9d174d}.activity-type-badge--import{background:#f1f5f9;color:#475569}.activity-type-badge--note{background:#ecfdf5;color:#065f46}.text-nowrap{white-space:nowrap}.orders-money{display:grid;gap:2px}.orders-money__main{color:#0f172a;font-weight:700;font-size:14px}.orders-money__meta{color:#64748b;font-size:12px}.table-list[data-table-list-id=orders]{gap:8px}.table-list[data-table-list-id=orders] .table-list__header{padding:2px 0 0}.table-list[data-table-list-id=orders] .table-list-filters{gap:8px;grid-template-columns:repeat(auto-fit, minmax(150px, 1fr))}.table-list[data-table-list-id=orders] .table th,.table-list[data-table-list-id=orders] .table td{padding:6px 8px}.table-list[data-table-list-id=orders] .table thead th{font-size:12px;text-transform:uppercase;letter-spacing:.02em;white-space:nowrap}.table-list[data-table-list-id=orders] .table tbody td{vertical-align:top;font-size:14px;line-height:1.25}.order-show-layout{display:grid;grid-template-columns:220px minmax(0, 1fr);gap:12px;align-items:start}.order-statuses-side{position:sticky;top:60px;padding:10px}.order-statuses-side__title{font-size:13px;font-weight:700;color:#0f172a;margin-bottom:8px}.order-status-group{margin-bottom:10px}.order-status-group__name{font-size:12px;color:#475569;font-weight:700;margin-bottom:5px}.order-status-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:4px 6px;border-radius:6px;color:#334155;font-size:12px;text-decoration:none}.order-status-row__count{min-width:24px;text-align:center;border-radius:999px;background:var(--status-color, #64748b);padding:1px 6px;font-weight:700;font-size:11px;color:#fff}.order-status-row:hover{background:#f1f5f9}.order-status-row.is-active{background:rgba(15,23,42,.06);color:#0f172a;font-weight:700}.order-show-main{min-width:0}.order-details-actions{display:inline-flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}.order-details-page{padding:12px}.order-details-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}.order-back-link{color:#475569;text-decoration:none;font-weight:600}.order-back-link:hover{color:#1d4ed8}.order-details-sub{display:inline-flex;gap:10px;flex-wrap:wrap;color:#64748b;font-size:12px}.order-details-pill{border-radius:999px;padding:5px 10px;background:#eef6ff;border:1px solid #cfe2ff;color:#1d4ed8;font-size:12px;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;flex-wrap:wrap}.order-details-tab{border:1px solid #d6deea;border-radius:8px;padding:5px 10px;color:#475569;font-size:12px;background:#f8fafc;cursor:pointer}.order-details-tab.is-active{border-color:#bfdbfe;color:#1d4ed8;background:#eff6ff;font-weight:700}.order-item-cell{display:grid;grid-template-columns:44px 1fr;gap:8px;align-items:center;min-width:260px}.order-item-thumb{width:44px;height:44px;border-radius:6px;border:1px solid #dbe3ef;object-fit:cover}.order-item-thumb--empty{display:inline-block;background:#eef2f7;border-style:dashed}.order-item-name{font-weight:600;color:#0f172a}.order-grid-2{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:12px}.order-grid-3{display:grid;grid-template-columns:repeat(3, minmax(0, 1fr));gap:12px}.order-kv{margin:0;display:grid;grid-template-columns:150px 1fr;gap:6px 10px;font-size:12px}.payment-summary{display:grid;gap:6px;max-width:420px}.payment-summary__row{display:flex;align-items:center;gap:10px;font-size:12px}.payment-summary__label{width:150px;flex-shrink:0;color:#64748b}.payment-summary__value{font-weight:600;color:#0f172a}.order-kv dt{color:#64748b}.order-kv dd{margin:0;color:#0f172a;font-weight:600}.order-address{display:grid;gap:3px;font-size:12px;color:#0f172a}.order-events{display:grid;gap:8px}.order-event{border:1px solid #e2e8f0;border-radius:8px;padding:8px;background:#fbfdff}.order-event__head{color:#64748b;font-size:11px}.order-event__body{margin-top:4px;color:#0f172a;font-size:12px}.order-tab-panel{display:none}.order-tab-panel.is-active{display:block}.order-empty-placeholder{border:1px dashed #cbd5e1;border-radius:8px;min-height:180px;background:#f8fafc}.order-status-badge{display:inline-flex;align-items:center;justify-content:center;padding:4px 10px;border-radius:999px;font-size:12px;font-weight:700;border:1px solid #cbd5e1;color:#334155;background:#f8fafc}.order-status-badge.is-info{border-color:#bfdbfe;background:#eff6ff;color:#1d4ed8}.order-status-badge.is-success{border-color:#bbf7d0;background:#f0fdf4;color:#166534}.order-status-badge.is-danger{border-color:#fecaca;background:#fef2f2;color:#b91c1c}.order-status-badge.is-warn{border-color:#fde68a;background:#fffbeb;color:#92400e}.order-status-badge.is-empty{color:#94a3b8}.order-buyer{display:grid;gap:2px}.order-buyer__name{color:#0f172a;font-weight:600}.order-buyer__email{color:#64748b;font-size:12px}.table-inline-action{display:inline-block;margin-right:6px}.product-name-cell{display:inline-flex;align-items:center;gap:10px}.product-name-thumb{width:60px;height:60px;border-radius:6px;object-fit:cover;border:1px solid var(--c-border);background:#f8fafc}.product-name-thumb--empty{display:inline-block;width:60px;height:60px;border-radius:6px;border:1px dashed #cbd5e0;background:#f8fafc}.product-name-thumb-btn{border:0;padding:0;background:rgba(0,0,0,0);cursor:pointer;display:inline-flex;align-items:center;justify-content:center}.product-name-thumb-btn:focus-visible{outline:none;box-shadow:var(--focus-ring);border-radius:8px}.modal--image-preview{width:min(760px,100%)}.product-image-preview__img{display:block;width:100%;max-height:70vh;object-fit:contain;border-radius:8px;background:#f8fafc}.product-images-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(220px, 1fr));gap:12px}.product-image-card{border:1px solid #dfe3ea;border-radius:10px;padding:10px;background:#fff}.product-image-card__thumb-wrap{position:relative;border-radius:8px;overflow:hidden;background:#f2f5f8}.product-image-card__thumb{width:100%;height:160px;object-fit:cover;display:block}.product-image-card__thumb.is-empty{height:160px;display:grid;place-items:center;color:#6b7785;font-size:12px}.product-image-card__badge{display:none;position:absolute;top:8px;left:8px;background:#1f7a43;color:#fff;padding:3px 8px;border-radius:999px;font-size:11px}.product-image-card.is-main .product-image-card__badge{display:inline-block}.product-image-card__meta{margin-top:8px;font-size:11px;line-height:1.25;color:#5f6b79;overflow-wrap:anywhere}.product-image-card__actions{margin-top:10px;display:grid;grid-template-columns:1fr;gap:8px}.product-image-card__actions .btn{min-height:34px;font-size:12px;line-height:1.2;padding:6px 10px}.product-links-search-form{display:grid;gap:12px;grid-template-columns:minmax(220px, 320px) minmax(220px, 1fr) auto;align-items:end}.product-links-head{display:grid;gap:8px;grid-template-columns:repeat(3, minmax(0, 1fr))}.product-tabs-nav{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.product-links-inline-form{display:grid;gap:8px;grid-template-columns:minmax(140px, 1fr) minmax(140px, 1fr) auto;align-items:center}.product-links-actions-row{display:flex;align-items:center;gap:8px;flex-wrap:nowrap}.product-links-actions-row .product-links-relink-form{flex:1 1 auto}.product-links-unlink-form{margin:0;flex:0 0 auto}.product-link-status-cell{display:inline-flex;align-items:center;gap:6px}.product-link-alert-indicator{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:999px;border:1px solid #f59e0b;background:#fffbeb;color:#b45309;font-size:12px;font-weight:700;cursor:help}.product-link-events-list{margin:0;padding:0;list-style:none;display:grid;gap:4px}.product-link-events-list li{display:grid;gap:2px}.product-link-events-type{font-weight:600;color:var(--c-text-strong)}.product-link-events-date{color:var(--c-muted);font-size:12px}.product-show-images-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(220px, 1fr));gap:12px}.product-show-image-card{border:1px solid var(--c-border);border-radius:10px;background:#fff;padding:10px;overflow:hidden}.product-show-image-card__meta{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;min-width:0}.product-show-image-path{font-size:12px;min-width:0;overflow:hidden}.product-show-image-path summary{cursor:pointer;color:var(--c-muted, #888);list-style:none;user-select:none;white-space:nowrap}.product-show-image-path summary::-webkit-details-marker{display:none}.product-show-image-path summary::after{content:" ▾"}.product-show-image-path[open] summary::after{content:" ▴"}.product-show-image-path__url{margin-top:4px;word-break:break-all;overflow-wrap:break-word;font-size:11px}.product-show-image{width:100%;max-height:260px;object-fit:cover;border-radius:8px;border:1px solid #d9e0ea}.shipment-grid{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:12px}.searchable-select{position:relative}.searchable-select__trigger{display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none;min-height:34px}.searchable-select__trigger::after{content:"";width:0;height:0;border-left:4px solid rgba(0,0,0,0);border-right:4px solid rgba(0,0,0,0);border-top:5px solid var(--c-text-muted, #6b7280);margin-left:8px;flex-shrink:0}.searchable-select__trigger--placeholder{color:var(--c-text-muted, #6b7280)}.searchable-select__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,.12)}.searchable-select__dropdown.is-open{display:block}.searchable-select__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}.searchable-select__option{padding:7px 10px;font-size:13px;cursor:pointer;color:var(--c-text-strong)}.searchable-select__option:hover{background:#f1f5f9}.searchable-select__option.is-selected{background:#edf2ff;font-weight:600}.flash{padding:10px 14px;border-radius:8px;font-size:13px;font-weight:500}.flash--success{background:#f0fdf4;border:1px solid #bbf7d0;color:#166534}.flash--error{background:#fef2f2;border:1px solid #fecaca;color:#b91c1c}.content-tabs-card{margin-top:0}.content-tabs-nav{display:flex;gap:4px;border-bottom:2px solid var(--c-border);margin-bottom:16px;flex-wrap:wrap}.content-tab-btn{padding:8px 16px;border:none;background:none;cursor:pointer;font-size:14px;font-weight:500;color:var(--c-text-muted, #6b7280);border-bottom:2px solid rgba(0,0,0,0);margin-bottom:-2px;border-radius:4px 4px 0 0;transition:color .15s,border-color .15s}.content-tab-btn:hover{color:var(--c-text-strong, #111827)}.content-tab-btn.is-active{color:var(--c-primary, #2563eb);border-bottom-color:var(--c-primary, #2563eb)}.content-tab-panel{display:none}.content-tab-panel.is-active{display:block}.shoppro-tabs-toolbar{display:flex;align-items:flex-end;justify-content:space-between;gap:10px;margin-bottom:10px;flex-wrap:wrap}.shoppro-tabs-toolbar__field{margin:0;min-width:260px;max-width:420px;flex:1 1 320px}.shoppro-tabs-toolbar__field .form-control{width:100%}.shoppro-tabs-toolbar__actions{display:inline-flex;align-items:center;gap:8px}.dm-carrier-select{min-width:140px}.dm-service-wrap{min-width:200px}.dm-service-wrap .dm-inpost-panel .form-control,.dm-service-wrap .dm-apaczka-panel .form-control{width:100%}.integration-settings-group{grid-column:1/-1;border:1px solid var(--c-border);border-radius:10px;background:#f8fbff;padding:10px}.integration-settings-group__head{margin-bottom:8px;padding:4px 8px;border-left:3px solid var(--c-primary, #2563eb);background:#eef4ff;border-radius:6px}.integration-settings-group__title{margin:0;font-size:14px;font-weight:700;letter-spacing:.01em;color:#1e3a8a}.integration-settings-group__desc{margin:4px 0 0;color:#4b5563}.integration-settings-group__grid{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:10px 12px;align-items:start}.integration-settings-group__full{grid-column:1/-1}.integration-settings-group__grid .form-field{margin:0;align-self:start}.integration-settings-group__grid .form-control{min-height:34px;height:34px}.integration-settings-group__grid input[type=date].form-control{line-height:1.2}.integration-settings-checkboxes{border:0;padding:0;margin:0}.integration-settings-checkboxes .field-label{display:block;margin-bottom:2px}.integration-settings-checkboxes__list{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:6px 12px}.integration-settings-checkboxes__item{display:inline-flex;align-items:center;gap:6px;font-size:13px;color:#334155}@media(max-width: 768px){.app-shell{flex-direction:column}.sidebar{width:100% !important;min-width:0 !important;border-right:0;border-bottom:1px solid #243041;padding:14px;overflow-x:auto}.sidebar__brand{margin:0 0 10px;font-size:22px}.sidebar__collapse-btn{display:none}.sidebar__nav{display:flex;gap:8px;overflow-x:auto}.sidebar__link{white-space:nowrap}.topbar{padding:0 14px}.container{margin-top:16px;width:calc(100% - 16px);margin-left:8px;margin-right:8px;padding:0 3px 12px}.settings-grid{grid-template-columns:1fr}.page-head{flex-direction:column;align-items:flex-start}.orders-stats{grid-template-columns:1fr;width:100%}.order-show-layout{grid-template-columns:1fr}.order-statuses-side{position:static;top:auto}.order-details-actions{justify-content:flex-start}.order-grid-2,.order-grid-3{grid-template-columns:1fr}.order-kv{grid-template-columns:1fr;gap:2px}.filters-grid,.form-grid,.form-grid-2,.form-grid-3,.form-grid-4,.shipment-grid,.statuses-form,.statuses-inline-form,.table-list-filters,.product-links-search-form,.product-links-inline-form{grid-template-columns:1fr}.statuses-dnd-item__content{display:block}.statuses-inline-delete{margin-top:6px}.filters-actions{align-items:center}.table-list__header,.table-list__footer{align-items:flex-start}.product-links-head{grid-template-columns:1fr}.integration-settings-group__grid{grid-template-columns:1fr}.integration-settings-checkboxes__list{grid-template-columns:1fr}.card{padding:12px}.modal--image-preview{width:min(92vw,100%)}} +:root{--c-primary: #6690f4;--c-primary-dark: #3164db;--c-bg: #f4f6f9;--c-surface: #ffffff;--c-text: #4e5e6a;--c-text-strong: #2d3748;--c-muted: #718096;--c-border: #b0bec5;--c-danger: #cc0000;--focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15);--shadow-card: 0 1px 4px rgba(0, 0, 0, 0.06)}.btn{display:inline-flex;align-items:center;justify-content:center;min-height:34px;padding:6px 12px;border:1px solid rgba(0,0,0,0);border-radius:8px;font:inherit;font-weight:600;text-decoration:none;cursor:pointer;transition:background-color .2s ease,border-color .2s ease,color .2s ease,transform .1s ease}.btn--primary{color:#fff;background:var(--c-primary)}.btn--primary:hover{background:var(--c-primary-dark)}.btn--secondary{color:var(--c-text-strong);border-color:var(--c-border);background:var(--c-surface)}.btn--secondary:hover{border-color:#cbd5e0;background:#f8fafc}.btn--danger{color:#fff;border-color:#b91c1c;background:#dc2626}.btn--danger:hover{border-color:#991b1b;background:#b91c1c}.btn--sm{min-height:28px;padding:3px 10px;font-size:12px}.btn--block{width:100%}.btn:active{transform:translateY(1px)}.btn:focus-visible{outline:none;box-shadow:var(--focus-ring);border-color:var(--c-primary)}.form-control{width:100%;min-height:34px;border:1px solid var(--c-border);border-radius:8px;padding:5px 10px;font:inherit;color:var(--c-text-strong);background:#fff;transition:border-color .2s ease,box-shadow .2s ease}.form-control:focus{outline:none;border-color:var(--c-primary);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:#fff}.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;border:1px solid rgba(0,0,0,0);font-size:13px;min-height:44px}.alert--danger{border-color:#fed7d7;background:#fff5f5;color:var(--c-danger)}.alert--success{border-color:#b7ebcf;background:#f0fff6;color:#0f6b39}.alert--warning{border-color:#f7dd8b;background:#fff8e8;color:#815500}.form-field{display:grid;gap:5px}.field-label{color:var(--c-text-strong);font-size:13px;font-weight:600}.table-wrap{width:100%;overflow-x:auto}.table-wrap--visible{overflow:visible !important;overflow-x:visible !important}.table{width:100%;border-collapse:collapse;background:var(--c-surface)}.table th,.table td{padding:10px 12px;border-bottom:1px solid var(--c-border);text-align:left}.table th{color:var(--c-text-strong);font-weight:700;background:#f8fafc}.table--details th{white-space:nowrap}.table--details th:first-child,.table--details td:first-child{width:36px;text-align:center}.pagination{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.pagination__item{display:inline-flex;align-items:center;justify-content:center;min-width:36px;height:36px;padding:0 10px;border-radius:8px;border:1px solid var(--c-border);color:var(--c-text-strong);background:var(--c-surface);text-decoration:none;font-weight:600}.pagination__item:hover{border-color:#cbd5e0;background:#f8fafc}.pagination__item.is-active{border-color:var(--c-primary);color:var(--c-primary);background:#edf2ff}*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;font-family:"Roboto","Segoe UI",sans-serif;font-size:13px;color:var(--c-text);background:var(--c-bg)}a{color:var(--c-primary)}.app-shell{min-height:100vh;display:flex}.sidebar{width:260px;min-width:260px;flex-shrink:0;overflow:hidden;transition:width .22s ease,min-width .22s ease;border-right:1px solid #243041;background:#111a28;padding:18px 10px;display:flex;flex-direction:column}.sidebar.is-collapsed{width:52px;min-width:52px}.sidebar__brand{display:flex;align-items:center;justify-content:space-between;margin:4px 4px 16px;gap:6px;min-width:0}.sidebar__brand-text{color:#e9f0ff;font-size:24px;font-weight:300;letter-spacing:-0.02em;white-space:nowrap;overflow:hidden;flex:1;min-width:0}.sidebar__brand-text strong{font-weight:700}.sidebar__collapse-btn{flex-shrink:0;width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0);border:1px solid #2a3a54;border-radius:6px;color:#64748b;cursor:pointer;padding:0;transition:background .15s,color .15s}.sidebar__collapse-btn:hover{background:#1b2a3f;color:#cbd5e1}.sidebar__collapse-icon{display:block;transition:transform .22s ease;flex-shrink:0}.sidebar.is-collapsed .sidebar__collapse-icon{transform:rotate(180deg)}.sidebar__nav{display:grid;gap:4px}.sidebar__link{border-radius:8px;padding:10px 12px;text-decoration:none;color:#cbd5e1;font-weight:600}.sidebar__link:hover{color:#f8fafc;background:#1b2a3f}.sidebar__link.is-active{color:#fff;background:#2e4f93}.sidebar__group{display:grid;gap:2px}.sidebar__group-toggle{list-style:none;border-radius:8px;padding:9px 10px;color:#cbd5e1;font-weight:600;cursor:pointer;display:flex;align-items:center;gap:9px;white-space:nowrap;user-select:none}.sidebar__group-toggle::-webkit-details-marker{display:none}.sidebar__group:hover .sidebar__group-toggle,.sidebar__group-toggle:hover{color:#f8fafc;background:#1b2a3f}.sidebar__group.is-active .sidebar__group-toggle{color:#fff;background:#2e4f93}.sidebar__icon{flex-shrink:0;width:18px;height:18px;display:flex;align-items:center;justify-content:center;opacity:.85}.sidebar__label{flex:1;min-width:0;overflow:hidden}.sidebar__toggle-arrow{flex-shrink:0;margin-left:auto;opacity:.5;transition:transform .18s ease}details[open]>.sidebar__group-toggle .sidebar__toggle-arrow{transform:rotate(180deg)}.sidebar__group-links{display:grid;gap:2px;padding-left:12px;overflow:hidden}.sidebar__sublink{border-radius:6px;padding:7px 10px 7px 8px;text-decoration:none;color:#94a3b8;font-size:12.5px;font-weight:500;display:flex;align-items:center;gap:8px;white-space:nowrap}.sidebar__sublink::before{content:"";flex-shrink:0;width:5px;height:5px;border-radius:50%;background:rgba(148,163,184,.3);transition:background .15s}.sidebar__sublink:hover{color:#e2e8f0;background:#1b2a3f}.sidebar__sublink:hover::before{background:rgba(148,163,184,.65)}.sidebar__sublink.is-active{color:#fff;background:rgba(46,79,147,.55)}.sidebar__sublink.is-active::before{background:#93c5fd}.app-main{flex:1;min-width:0}.topbar{height:50px;border-bottom:1px solid var(--c-border);background:var(--c-surface);display:flex;align-items:center;justify-content:space-between;padding:0 20px;position:sticky;top:0;z-index:100}.brand{font-size:22px;font-weight:300;letter-spacing:-0.02em;color:var(--c-text-strong)}.brand strong{font-weight:700}.container{max-width:none;width:calc(100% - 20px);margin:12px 10px;padding:0 4px 14px}.card{background:var(--c-surface);border-radius:10px;box-shadow:var(--shadow-card);padding:14px}.card h1{margin:0 0 10px;color:var(--c-text-strong);font-size:24px;font-weight:700}.muted{color:var(--c-muted)}.accent{color:var(--c-primary);font-weight:600}.users-form{display:grid;gap:14px;max-width:460px}.section-title{margin:0;color:var(--c-text-strong);font-size:18px;font-weight:700}h2.section-title,h3.section-title,h4.section-title{display:flex;align-items:center;gap:8px;font-weight:400;padding:5px 10px;border-left:3px solid var(--c-primary);border-radius:7px;background:linear-gradient(180deg, #f4f8ff 0%, #edf3ff 100%);color:#1e3a8a;box-shadow:inset 0 0 0 1px #dbe7fb}.mt-0{margin-top:0}.mt-4{margin-top:4px}.mt-12{margin-top:8px}.mt-16{margin-top:12px}.settings-grid{display:grid;grid-template-columns:repeat(3, minmax(0, 1fr));gap:12px}.settings-nav{display:flex;gap:8px;flex-wrap:wrap}.settings-nav__link{text-decoration:none;border:1px solid var(--c-border);border-radius:8px;padding:8px 12px;color:var(--c-text-strong);font-weight:600}.settings-nav__link:hover{background:#f8fafc}.settings-nav__link.is-active{border-color:var(--c-primary);color:var(--c-primary);background:#edf2ff}.settings-stat{border:1px solid var(--c-border);border-radius:8px;padding:12px;background:#f8fafc}.settings-stat__label{display:block;color:var(--c-muted);font-size:12px;margin-bottom:4px}.settings-stat__value{color:var(--c-text-strong);font-size:20px}.settings-logs{margin:0;padding:12px;border-radius:8px;border:1px solid var(--c-border);background:#0b1220;color:#d1d5db;font-size:12px;line-height:1.5;overflow:auto}.settings-allegro-callback{display:block;width:100%;padding:8px 10px;border:1px solid var(--c-border);border-radius:8px;background:#f8fafc;color:var(--c-text-strong);font-size:12px;line-height:1.45;word-break:break-all}.page-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.filters-grid{display:grid;grid-template-columns:repeat(3, minmax(0, 1fr));gap:12px}.filters-actions{display:flex;align-items:end;gap:8px}.product-form .form-control{width:100%}.form-grid{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));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;flex-wrap:wrap;align-items:flex-start}.form-actions .btn{align-self:flex-start}.statuses-form{display:grid;gap:8px;grid-template-columns:repeat(2, minmax(0, 1fr))}.statuses-form .form-actions{grid-column:1/-1}.statuses-color-input{min-height:32px;padding:2px}.statuses-hint{grid-column:1/-1;margin:0}.statuses-group-block{border:1px solid var(--c-border);border-radius:10px;padding:8px;background:#fbfdff}.statuses-group-block__head{display:flex;align-items:center;justify-content:space-between;gap:6px;flex-wrap:wrap}.statuses-group-block__title{margin:0;display:inline-flex;align-items:center;gap:6px;color:var(--c-text-strong);font-size:14px}.statuses-color-dot{width:12px;height:12px;border-radius:999px;border:1px solid rgba(15,23,42,.15)}.statuses-dnd-list{margin:6px 0 0;padding:0;list-style:none;display:grid;gap:6px}.statuses-dnd-item{display:grid;grid-template-columns:24px 1fr;gap:6px;border:1px solid #dce4f0;border-radius:8px;background:#fff;padding:6px}.statuses-dnd-item__content{display:flex;align-items:center;gap:6px;min-width:0}.statuses-dnd-item.is-dragging{opacity:.6}.statuses-dnd-item__drag{display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;border-radius:6px;color:#64748b;cursor:grab;user-select:none;font-weight:700;font-size:12px}.statuses-dnd-item__drag:active{cursor:grabbing}.statuses-inline-form{display:grid;gap:6px}.statuses-inline-form--row{grid-template-columns:minmax(180px, 1.4fr) minmax(150px, 1fr) auto auto auto;align-items:center;flex:1 1 auto;min-width:0}.statuses-inline-form--row-group{grid-template-columns:minmax(180px, 1.5fr) 56px auto auto auto;align-items:center;flex:1 1 auto;min-width:0}.statuses-inline-form--row .form-control,.statuses-inline-form--row-group .form-control{min-height:30px;padding:4px 8px}.statuses-inline-form--row .btn,.statuses-inline-form--row-group .btn,.statuses-inline-delete .btn{min-height:30px;padding:4px 10px;font-size:12px}.statuses-inline-check{margin-top:0;white-space:nowrap;font-size:12px}.statuses-inline-delete{margin:0;flex:0 0 auto}.statuses-code-label{font-size:12px;color:var(--c-muted)}.statuses-code-readonly{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;font-size:12px}.statuses-code-readonly code{background:#eef2f7;border-radius:6px;padding:1px 6px;color:#1f2937;font-size:12px}.field-inline{display:flex;align-items:center;gap:8px;margin-top:2px}.modal-backdrop{position:fixed;inset:0;background:rgba(15,23,42,.5);display:flex;align-items:center;justify-content:center;padding:16px;z-index:200}.modal-backdrop[hidden]{display:none}.modal{width:min(560px,100%);background:#fff;border-radius:10px;box-shadow:0 20px 40px rgba(15,23,42,.35);overflow:hidden}.modal__header{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:16px 18px;border-bottom:1px solid var(--c-border)}.modal__header h3{margin:0;font-size:18px;color:var(--c-text-strong)}.modal__body{padding:16px 18px 18px}.status-pill{display:inline-flex;align-items:center;justify-content:center;border:1px solid #fed7d7;background:#fff5f5;color:#9b2c2c;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:600}.status-pill.is-active{border-color:#b7ebcf;background:#f0fff6;color:#0f6b39}.table-row-actions{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap}.table-row-actions form{margin:0}.table-list{display:grid;gap:14px}.table-list__header{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap}.table-list__left{display:inline-flex;align-items:center;gap:8px;flex-wrap:wrap}.table-list-header-actions{display:inline-flex;align-items:center;gap:10px;flex-wrap:wrap}.js-filter-toggle-btn.is-active{border-color:#cbd5e0;background:#edf2ff;color:var(--c-primary-dark)}.table-filter-badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;font-size:11px;font-weight:700;color:#fff;background:var(--c-primary);border-radius:999px}.table-filters-wrapper{display:none}.table-filters-wrapper.is-open{display:block}.table-list-filters{display:grid;gap:12px;grid-template-columns:repeat(auto-fit, minmax(170px, 1fr))}.table-col-toggle-wrapper{position:relative}.table-col-toggle-dropdown{display:none;position:absolute;right:0;top:calc(100% + 6px);z-index:30;width:260px;max-height:360px;overflow:auto;border:1px solid var(--c-border);border-radius:10px;background:#fff;box-shadow:0 10px 25px rgba(15,23,42,.12)}.table-col-toggle-dropdown.is-open{display:block}.table-col-toggle-header{padding:10px 12px;border-bottom:1px solid var(--c-border);font-size:12px;font-weight:700;color:var(--c-muted)}.table-col-toggle-item{display:flex;align-items:center;gap:10px;padding:8px 12px;font-size:13px;color:var(--c-text-strong)}.table-col-toggle-item:hover{background:#f8fafc}.table-col-toggle-footer{border-top:1px solid var(--c-border);padding:8px 12px}.table-col-hidden{display:none}.table-col-switch{position:relative;display:inline-block;width:34px;min-width:34px;height:18px}.table-col-switch input{opacity:0;width:0;height:0;position:absolute}.table-col-switch-slider{position:absolute;top:0;left:0;right:0;bottom:0;background:#cbd5e1;border-radius:999px;transition:background-color .2s ease}.table-col-switch-slider::before{content:"";position:absolute;height:14px;width:14px;left:2px;bottom:2px;background:#fff;border-radius:50%;transition:transform .2s ease}.table-col-switch input:checked+.table-col-switch-slider{background:#16a34a}.table-col-switch input:checked+.table-col-switch-slider::before{transform:translateX(16px)}.table-sort-link{display:inline-flex;align-items:center;gap:6px;color:var(--c-text-strong);text-decoration:none}.table-sort-link:hover{color:var(--c-primary-dark)}.table-sort-icon.is-muted{color:#a0aec0}.table-list__footer{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}.table-list-per-page-form{display:inline-flex;align-items:center;gap:8px}.table-list-per-page-form .form-control{min-width:90px}.table-select-col{width:44px;text-align:center}.table-select-toggle{display:inline-flex;align-items:center;justify-content:center}.table-select-toggle input[type=checkbox]{width:16px;height:16px}.orders-page .orders-head{background:linear-gradient(120deg, #f8fbff 0%, #eef5ff 100%);border:1px solid #dbe7fb}.orders-page .table-list{border:1px solid #dde5f2;border-radius:12px;box-shadow:0 6px 16px rgba(20,44,86,.08)}.orders-page .table-list__header{padding:10px 6px 2px}.orders-page .table-list-filters{padding:6px 6px 2px;border-top:1px solid #ebf0f7;border-bottom:1px solid #ebf0f7;background:#f9fbff}.orders-page .table-wrap{border-radius:10px;overflow:hidden;border:1px solid #e7edf6}.orders-page .table thead th{background:#f3f7fd;color:#30435f;font-size:12px;text-transform:uppercase;letter-spacing:.03em}.orders-page .table tbody td{vertical-align:middle;padding-top:10px;padding-bottom:10px;border-bottom-color:#edf2f8}.orders-page .table tbody tr:hover td{background:#f9fcff}.orders-list-page{padding:10px;margin-bottom:10px}.orders-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}.orders-stats{display:inline-grid;grid-template-columns:repeat(3, minmax(86px, auto));gap:8px}.orders-stat{border:1px solid #d8e2f0;background:#f8fbff;border-radius:8px;padding:6px 8px;line-height:1.15}.orders-stat__label{display:block;color:#5f6f83;font-size:11px;margin-bottom:2px}.orders-stat__value{color:#12233a;font-size:16px;font-weight:700}.orders-ref{display:grid;gap:2px;min-width:170px}.orders-ref__main{font-weight:700;color:#0f1f35;font-size:14px}.orders-ref__meta{display:inline-flex;flex-wrap:wrap;gap:4px 10px;color:#64748b;font-size:12px}.orders-buyer{display:grid;gap:2px}.orders-buyer__name{color:#0f172a;font-weight:600;font-size:14px}.orders-buyer__meta{display:inline-flex;flex-wrap:wrap;gap:4px 10px;color:#64748b;font-size:12px}.orders-status-wrap{display:inline-flex;align-items:center;gap:5px;flex-wrap:wrap}.order-tag{display:inline-flex;align-items:center;justify-content:center;border:1px solid #d8e1ef;background:#f8fafc;color:#334155;border-radius:999px;padding:2px 8px;font-size:12px;font-weight:700;line-height:1.1}.order-tag.is-info{border-color:#bfdbfe;background:#eff6ff;color:#1d4ed8}.order-tag.is-success{border-color:#bbf7d0;background:#f0fdf4;color:#166534}.order-tag.is-danger{border-color:#fecaca;background:#fef2f2;color:#b91c1c}.order-tag.is-warn{border-color:#fde68a;background:#fffbeb;color:#92400e}.order-tag.is-cod{border-color:#f9a8d4;background:#fdf2f8;color:#9d174d}.order-tag.is-unpaid{border-color:#fca5a5;background:#fef2f2;color:#b91c1c}.orders-mini{font-size:14px;color:#223247;line-height:1.25}.orders-mini__delivery{font-size:12px;color:#64748b;margin-bottom:2px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.orders-products{display:grid;gap:4px;min-width:240px}.orders-products__meta,.orders-products__more{font-size:12px;color:#64748b}.orders-product{display:grid;grid-template-columns:48px 1fr;gap:6px;align-items:center}.orders-product__thumb{width:48px;height:48px;border-radius:4px;border:1px solid #dbe3ef;object-fit:cover;background:#fff}.orders-product__thumb--empty{display:inline-block;background:#eef2f7;border-style:dashed}.orders-product__txt{min-width:0;display:grid;gap:1px}.orders-product__name{font-size:14px;color:#0f172a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.orders-product__qty{font-size:12px;color:#64748b}.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,.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}.activity-type-badge--status_change{background:#dbeafe;color:#1e40af}.activity-type-badge--payment{background:#dcfce7;color:#166534}.activity-type-badge--invoice{background:#fef3c7;color:#92400e}.activity-type-badge--shipment{background:#e0e7ff;color:#3730a3}.activity-type-badge--message{background:#f3e8ff;color:#6b21a8}.activity-type-badge--document{background:#fce7f3;color:#9d174d}.activity-type-badge--import{background:#f1f5f9;color:#475569}.activity-type-badge--note{background:#ecfdf5;color:#065f46}.text-nowrap{white-space:nowrap}.orders-money{display:grid;gap:2px}.orders-money__main{color:#0f172a;font-weight:700;font-size:14px}.orders-money__meta{color:#64748b;font-size:12px}.table-list[data-table-list-id=orders]{gap:8px}.table-list[data-table-list-id=orders] .table-list__header{padding:2px 0 0}.table-list[data-table-list-id=orders] .table-list-filters{gap:8px;grid-template-columns:repeat(auto-fit, minmax(150px, 1fr))}.table-list[data-table-list-id=orders] .table th,.table-list[data-table-list-id=orders] .table td{padding:6px 8px}.table-list[data-table-list-id=orders] .table thead th{font-size:12px;text-transform:uppercase;letter-spacing:.02em;white-space:nowrap}.table-list[data-table-list-id=orders] .table tbody td{vertical-align:top;font-size:14px;line-height:1.25}.order-show-layout{display:grid;grid-template-columns:220px minmax(0, 1fr);gap:12px;align-items:start}.order-statuses-side{position:sticky;top:60px;padding:10px}.order-statuses-side__title{font-size:13px;font-weight:700;color:#0f172a;margin-bottom:8px}.order-status-group{margin-bottom:10px}.order-status-group__name{font-size:12px;color:#475569;font-weight:700;margin-bottom:5px}.order-status-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:4px 6px;border-radius:6px;color:#334155;font-size:12px;text-decoration:none}.order-status-row__count{min-width:24px;text-align:center;border-radius:999px;background:var(--status-color, #64748b);padding:1px 6px;font-weight:700;font-size:11px;color:#fff}.order-status-row:hover{background:#f1f5f9}.order-status-row.is-active{background:rgba(15,23,42,.06);color:#0f172a;font-weight:700}.order-show-main{min-width:0}.order-details-actions{display:inline-flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}.order-details-page{padding:12px}.order-details-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}.order-back-link{color:#475569;text-decoration:none;font-weight:600}.order-back-link:hover{color:#1d4ed8}.order-details-sub{display:inline-flex;gap:10px;flex-wrap:wrap;color:#64748b;font-size:12px}.order-details-pill{border-radius:999px;padding:5px 10px;background:#eef6ff;border:1px solid #cfe2ff;color:#1d4ed8;font-size:12px;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;flex-wrap:wrap}.order-details-tab{border:1px solid #d6deea;border-radius:8px;padding:5px 10px;color:#475569;font-size:12px;background:#f8fafc;cursor:pointer}.order-details-tab.is-active{border-color:#bfdbfe;color:#1d4ed8;background:#eff6ff;font-weight:700}.order-item-cell{display:grid;grid-template-columns:44px 1fr;gap:8px;align-items:center;min-width:260px}.order-item-thumb{width:44px;height:44px;border-radius:6px;border:1px solid #dbe3ef;object-fit:cover}.order-item-thumb--empty{display:inline-block;background:#eef2f7;border-style:dashed}.order-item-name{font-weight:600;color:#0f172a}.order-grid-2{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:12px}.order-grid-3{display:grid;grid-template-columns:repeat(3, minmax(0, 1fr));gap:12px}.order-kv{margin:0;display:grid;grid-template-columns:150px 1fr;gap:6px 10px;font-size:12px}.payment-summary{display:grid;gap:6px;max-width:420px}.payment-summary__row{display:flex;align-items:center;gap:10px;font-size:12px}.payment-summary__label{width:150px;flex-shrink:0;color:#64748b}.payment-summary__value{font-weight:600;color:#0f172a}.order-kv dt{color:#64748b}.order-kv dd{margin:0;color:#0f172a;font-weight:600}.order-address{display:grid;gap:3px;font-size:12px;color:#0f172a}.order-events{display:grid;gap:8px}.order-event{border:1px solid #e2e8f0;border-radius:8px;padding:8px;background:#fbfdff}.order-event__head{color:#64748b;font-size:11px}.order-event__body{margin-top:4px;color:#0f172a;font-size:12px}.order-tab-panel{display:none}.order-tab-panel.is-active{display:block}.order-empty-placeholder{border:1px dashed #cbd5e1;border-radius:8px;min-height:180px;background:#f8fafc}.order-status-badge{display:inline-flex;align-items:center;justify-content:center;padding:4px 10px;border-radius:999px;font-size:12px;font-weight:700;border:1px solid #cbd5e1;color:#334155;background:#f8fafc}.order-status-badge.is-info{border-color:#bfdbfe;background:#eff6ff;color:#1d4ed8}.order-status-badge.is-success{border-color:#bbf7d0;background:#f0fdf4;color:#166534}.order-status-badge.is-danger{border-color:#fecaca;background:#fef2f2;color:#b91c1c}.order-status-badge.is-warn{border-color:#fde68a;background:#fffbeb;color:#92400e}.order-status-badge.is-empty{color:#94a3b8}.order-buyer{display:grid;gap:2px}.order-buyer__name{color:#0f172a;font-weight:600}.order-buyer__email{color:#64748b;font-size:12px}.table-inline-action{display:inline-block;margin-right:6px}.product-name-cell{display:inline-flex;align-items:center;gap:10px}.product-name-thumb{width:60px;height:60px;border-radius:6px;object-fit:cover;border:1px solid var(--c-border);background:#f8fafc}.product-name-thumb--empty{display:inline-block;width:60px;height:60px;border-radius:6px;border:1px dashed #cbd5e0;background:#f8fafc}.product-name-thumb-btn{border:0;padding:0;background:rgba(0,0,0,0);cursor:pointer;display:inline-flex;align-items:center;justify-content:center}.product-name-thumb-btn:focus-visible{outline:none;box-shadow:var(--focus-ring);border-radius:8px}.modal--image-preview{width:min(760px,100%)}.product-image-preview__img{display:block;width:100%;max-height:70vh;object-fit:contain;border-radius:8px;background:#f8fafc}.product-images-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(220px, 1fr));gap:12px}.product-image-card{border:1px solid #dfe3ea;border-radius:10px;padding:10px;background:#fff}.product-image-card__thumb-wrap{position:relative;border-radius:8px;overflow:hidden;background:#f2f5f8}.product-image-card__thumb{width:100%;height:160px;object-fit:cover;display:block}.product-image-card__thumb.is-empty{height:160px;display:grid;place-items:center;color:#6b7785;font-size:12px}.product-image-card__badge{display:none;position:absolute;top:8px;left:8px;background:#1f7a43;color:#fff;padding:3px 8px;border-radius:999px;font-size:11px}.product-image-card.is-main .product-image-card__badge{display:inline-block}.product-image-card__meta{margin-top:8px;font-size:11px;line-height:1.25;color:#5f6b79;overflow-wrap:anywhere}.product-image-card__actions{margin-top:10px;display:grid;grid-template-columns:1fr;gap:8px}.product-image-card__actions .btn{min-height:34px;font-size:12px;line-height:1.2;padding:6px 10px}.product-links-search-form{display:grid;gap:12px;grid-template-columns:minmax(220px, 320px) minmax(220px, 1fr) auto;align-items:end}.product-links-head{display:grid;gap:8px;grid-template-columns:repeat(3, minmax(0, 1fr))}.product-tabs-nav{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.product-links-inline-form{display:grid;gap:8px;grid-template-columns:minmax(140px, 1fr) minmax(140px, 1fr) auto;align-items:center}.product-links-actions-row{display:flex;align-items:center;gap:8px;flex-wrap:nowrap}.product-links-actions-row .product-links-relink-form{flex:1 1 auto}.product-links-unlink-form{margin:0;flex:0 0 auto}.product-link-status-cell{display:inline-flex;align-items:center;gap:6px}.product-link-alert-indicator{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:999px;border:1px solid #f59e0b;background:#fffbeb;color:#b45309;font-size:12px;font-weight:700;cursor:help}.product-link-events-list{margin:0;padding:0;list-style:none;display:grid;gap:4px}.product-link-events-list li{display:grid;gap:2px}.product-link-events-type{font-weight:600;color:var(--c-text-strong)}.product-link-events-date{color:var(--c-muted);font-size:12px}.product-show-images-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(220px, 1fr));gap:12px}.product-show-image-card{border:1px solid var(--c-border);border-radius:10px;background:#fff;padding:10px;overflow:hidden}.product-show-image-card__meta{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;min-width:0}.product-show-image-path{font-size:12px;min-width:0;overflow:hidden}.product-show-image-path summary{cursor:pointer;color:var(--c-muted, #888);list-style:none;user-select:none;white-space:nowrap}.product-show-image-path summary::-webkit-details-marker{display:none}.product-show-image-path summary::after{content:" ▾"}.product-show-image-path[open] summary::after{content:" ▴"}.product-show-image-path__url{margin-top:4px;word-break:break-all;overflow-wrap:break-word;font-size:11px}.product-show-image{width:100%;max-height:260px;object-fit:cover;border-radius:8px;border:1px solid #d9e0ea}.shipment-grid{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:12px}.searchable-select{position:relative}.searchable-select__trigger{display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none;min-height:34px}.searchable-select__trigger::after{content:"";width:0;height:0;border-left:4px solid rgba(0,0,0,0);border-right:4px solid rgba(0,0,0,0);border-top:5px solid var(--c-text-muted, #6b7280);margin-left:8px;flex-shrink:0}.searchable-select__trigger--placeholder{color:var(--c-text-muted, #6b7280)}.searchable-select__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,.12)}.searchable-select__dropdown.is-open{display:block}.searchable-select__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}.searchable-select__option{padding:7px 10px;font-size:13px;cursor:pointer;color:var(--c-text-strong)}.searchable-select__option:hover{background:#f1f5f9}.searchable-select__option.is-selected{background:#edf2ff;font-weight:600}.flash{padding:10px 14px;border-radius:8px;font-size:13px;font-weight:500}.flash--success{background:#f0fdf4;border:1px solid #bbf7d0;color:#166534}.flash--error{background:#fef2f2;border:1px solid #fecaca;color:#b91c1c}.content-tabs-card{margin-top:0}.content-tabs-nav{display:flex;gap:4px;border-bottom:2px solid var(--c-border);margin-bottom:16px;flex-wrap:wrap}.content-tab-btn{padding:8px 16px;border:none;background:none;cursor:pointer;font-size:14px;font-weight:500;color:var(--c-text-muted, #6b7280);border-bottom:2px solid rgba(0,0,0,0);margin-bottom:-2px;border-radius:4px 4px 0 0;transition:color .15s,border-color .15s}.content-tab-btn:hover{color:var(--c-text-strong, #111827)}.content-tab-btn.is-active{color:var(--c-primary, #2563eb);border-bottom-color:var(--c-primary, #2563eb)}.content-tab-panel{display:none}.content-tab-panel.is-active{display:block}.shoppro-tabs-toolbar{display:flex;align-items:flex-end;justify-content:space-between;gap:10px;margin-bottom:10px;flex-wrap:wrap}.shoppro-tabs-toolbar__field{margin:0;min-width:260px;max-width:420px;flex:1 1 320px}.shoppro-tabs-toolbar__field .form-control{width:100%}.shoppro-tabs-toolbar__actions{display:inline-flex;align-items:center;gap:8px}.dm-carrier-select{min-width:140px}.dm-service-wrap{min-width:200px}.dm-service-wrap .dm-inpost-panel .form-control,.dm-service-wrap .dm-apaczka-panel .form-control{width:100%}.integration-settings-group{grid-column:1/-1;border:1px solid var(--c-border);border-radius:10px;background:#f8fbff;padding:10px}.integration-settings-group__head{margin-bottom:8px;padding:4px 8px;border-left:3px solid var(--c-primary, #2563eb);background:#eef4ff;border-radius:6px}.integration-settings-group__title{margin:0;font-size:14px;font-weight:700;letter-spacing:.01em;color:#1e3a8a}.integration-settings-group__desc{margin:4px 0 0;color:#4b5563}.integration-settings-group__grid{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:10px 12px;align-items:start}.integration-settings-group__full{grid-column:1/-1}.integration-settings-group__grid .form-field{margin:0;align-self:start}.integration-settings-group__grid .form-control{min-height:34px;height:34px}.integration-settings-group__grid input[type=date].form-control{line-height:1.2}.integration-settings-checkboxes{border:0;padding:0;margin:0}.integration-settings-checkboxes .field-label{display:block;margin-bottom:2px}.integration-settings-checkboxes__list{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:6px 12px}.integration-settings-checkboxes__item{display:inline-flex;align-items:center;gap:6px;font-size:13px;color:#334155}@media(max-width: 768px){.app-shell{flex-direction:column}.sidebar{width:100% !important;min-width:0 !important;border-right:0;border-bottom:1px solid #243041;padding:14px;overflow-x:auto}.sidebar__brand{margin:0 0 10px;font-size:22px}.sidebar__collapse-btn{display:none}.sidebar__nav{display:flex;gap:8px;overflow-x:auto}.sidebar__link{white-space:nowrap}.topbar{padding:0 14px}.container{margin-top:16px;width:calc(100% - 16px);margin-left:8px;margin-right:8px;padding:0 3px 12px}.settings-grid{grid-template-columns:1fr}.page-head{flex-direction:column;align-items:flex-start}.orders-stats{grid-template-columns:1fr;width:100%}.order-show-layout{grid-template-columns:1fr}.order-statuses-side{position:static;top:auto}.order-details-actions{justify-content:flex-start}.order-grid-2,.order-grid-3{grid-template-columns:1fr}.order-kv{grid-template-columns:1fr;gap:2px}.filters-grid,.form-grid,.form-grid-2,.form-grid-3,.form-grid-4,.shipment-grid,.statuses-form,.statuses-inline-form,.table-list-filters,.product-links-search-form,.product-links-inline-form{grid-template-columns:1fr}.statuses-dnd-item__content{display:block}.statuses-inline-delete{margin-top:6px}.filters-actions{align-items:center}.table-list__header,.table-list__footer{align-items:flex-start}.product-links-head{grid-template-columns:1fr}.integration-settings-group__grid{grid-template-columns:1fr}.integration-settings-checkboxes__list{grid-template-columns:1fr}.card{padding:12px}.modal--image-preview{width:min(92vw,100%)}} diff --git a/resources/scss/shared/_ui-components.scss b/resources/scss/shared/_ui-components.scss index f901ab1..61f3a36 100644 --- a/resources/scss/shared/_ui-components.scss +++ b/resources/scss/shared/_ui-components.scss @@ -6,7 +6,7 @@ --c-text: #4e5e6a; --c-text-strong: #2d3748; --c-muted: #718096; - --c-border: #e2e8f0; + --c-border: #b0bec5; --c-danger: #cc0000; --focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15); --shadow-card: 0 1px 4px rgba(0, 0, 0, 0.06); diff --git a/resources/views/settings/allegro.php b/resources/views/settings/allegro.php index b7e857a..59971bc 100644 --- a/resources/views/settings/allegro.php +++ b/resources/views/settings/allegro.php @@ -261,8 +261,8 @@ $orderproStatuses = is_array($orderproStatuses ?? null) ? $orderproStatuses : [] - diff --git a/src/Core/Application.php b/src/Core/Application.php index f22f475..25af9a9 100644 --- a/src/Core/Application.php +++ b/src/Core/Application.php @@ -269,16 +269,42 @@ final class Application { $safeInterval = max(1, $minIntervalSeconds); $now = time(); - $lastRunAt = isset($_SESSION['cron_web_last_run_at']) ? (int) $_SESSION['cron_web_last_run_at'] : 0; + $lastRunAt = $this->getWebCronLastRunAt(); if ($lastRunAt > 0 && ($now - $lastRunAt) < $safeInterval) { return true; } - $_SESSION['cron_web_last_run_at'] = $now; + $this->setWebCronLastRunAt($now); return false; } + private function getWebCronLastRunAt(): int + { + try { + $stmt = $this->db->prepare( + "SELECT setting_value FROM app_settings WHERE setting_key = 'cron_web_last_run_at'" + ); + $stmt->execute(); + $value = $stmt->fetchColumn(); + return $value !== false ? (int) $value : 0; + } catch (Throwable) { + return 0; + } + } + + private function setWebCronLastRunAt(int $timestamp): void + { + try { + $this->db->prepare( + "INSERT INTO app_settings (setting_key, setting_value, created_at, updated_at) + VALUES ('cron_web_last_run_at', :ts, NOW(), NOW()) + ON DUPLICATE KEY UPDATE setting_value = :ts2, updated_at = NOW()" + )->execute(['ts' => (string) $timestamp, 'ts2' => (string) $timestamp]); + } catch (Throwable) { + } + } + private function acquireWebCronLock(): bool { $statement = $this->db->query("SELECT GET_LOCK('orderpro_web_cron_lock', 0)"); diff --git a/src/Modules/Orders/OrdersController.php b/src/Modules/Orders/OrdersController.php index 2a281e0..99c0175 100644 --- a/src/Modules/Orders/OrdersController.php +++ b/src/Modules/Orders/OrdersController.php @@ -47,10 +47,11 @@ final class OrdersController $statusCounts = $this->orders->statusCounts(); $statusConfig = $this->orders->statusPanelConfig(); $statusLabelMap = $this->statusLabelMap($statusConfig); + $statusColorMap = $this->statusColorMap($statusConfig); $statusOptions = $this->buildStatusFilterOptions($this->orders->statusOptions(), $statusLabelMap); $statusPanel = $this->buildStatusPanel($statusConfig, $statusCounts, $filters['status'], $filters); - $tableRows = array_map(fn (array $row): array => $this->toTableRow($row, $statusLabelMap), (array) ($result['items'] ?? [])); + $tableRows = array_map(fn (array $row): array => $this->toTableRow($row, $statusLabelMap, $statusColorMap), (array) ($result['items'] ?? [])); $html = $this->template->render('orders/list', [ 'title' => $this->translator->get('orders.title'), @@ -228,7 +229,7 @@ final class OrdersController * @param array $row * @return array */ - private function toTableRow(array $row, array $statusLabelMap): array + private function toTableRow(array $row, array $statusLabelMap, array $statusColorMap = []): array { $internalOrderNumber = trim((string) ($row['internal_order_number'] ?? '')); $sourceOrderId = trim((string) ($row['source_order_id'] ?? '')); @@ -257,8 +258,8 @@ final class OrdersController . htmlspecialchars($internalOrderNumber !== '' ? $internalOrderNumber : ('#' . (string) ($row['id'] ?? 0)), ENT_QUOTES, 'UTF-8') . '' . '
' - . '' . htmlspecialchars($sourceOrderId !== '' ? $sourceOrderId : $externalOrderId, ENT_QUOTES, 'UTF-8') . '' - . '' . htmlspecialchars($source, ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars($this->sourceLabel($source), ENT_QUOTES, 'UTF-8') . '' + . 'ID: ' . htmlspecialchars($sourceOrderId !== '' ? $sourceOrderId : $externalOrderId, ENT_QUOTES, 'UTF-8') . '' . '
' . '', 'buyer' => '
' @@ -269,7 +270,7 @@ final class OrdersController . '
' . '', 'status_badges' => '
' - . $this->statusBadge($status, $this->statusLabel($status, $statusLabelMap)) + . $this->statusBadge($status, $this->statusLabel($status, $statusLabelMap), $statusColorMap[strtolower(trim($status))] ?? '') . '
', 'products' => $this->productsHtml($itemsPreview, $itemsCount, $itemsQty), 'totals' => '
' @@ -285,10 +286,16 @@ final class OrdersController ]; } - private function statusBadge(string $statusCode, string $statusLabel): string + private function statusBadge(string $statusCode, string $statusLabel, string $colorHex = ''): string { $label = $statusLabel !== '' ? $statusLabel : '-'; $code = strtolower(trim($statusCode)); + + if ($colorHex !== '') { + $style = 'background-color:' . htmlspecialchars($colorHex, ENT_QUOTES, 'UTF-8') . ';color:#fff'; + return '' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . ''; + } + $class = 'is-neutral'; if (in_array($code, ['shipped', 'delivered'], true)) { $class = 'is-success'; @@ -303,6 +310,16 @@ final class OrdersController return '' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . ''; } + private function sourceLabel(string $source): string + { + return match (strtolower(trim($source))) { + 'allegro' => 'Allegro', + 'shoppro' => 'shopPRO', + 'erli' => 'Erli', + default => ucfirst(strtolower(trim($source))), + }; + } + private function statusLabel(string $statusCode, array $statusLabelMap = []): string { $key = strtolower(trim($statusCode)); @@ -475,6 +492,30 @@ final class OrdersController return $map; } + /** + * @param array}> $config + * @return array + */ + private function statusColorMap(array $config): array + { + $map = []; + foreach ($config as $group) { + $groupColor = StringHelper::normalizeColorHex((string) ($group['color_hex'] ?? '')); + if ($groupColor === '') { + continue; + } + $items = is_array($group['items'] ?? null) ? $group['items'] : []; + foreach ($items as $item) { + $code = strtolower(trim((string) ($item['code'] ?? ''))); + if ($code !== '') { + $map[$code] = $groupColor; + } + } + } + + return $map; + } + /** * @param array $statusCodes * @param array $statusLabelMap diff --git a/src/Modules/Orders/OrdersRepository.php b/src/Modules/Orders/OrdersRepository.php index 8580615..b1f1ca5 100644 --- a/src/Modules/Orders/OrdersRepository.php +++ b/src/Modules/Orders/OrdersRepository.php @@ -9,7 +9,7 @@ use Throwable; final class OrdersRepository { - private ?bool $supportsMappedMedia = null; + private static ?bool $supportsMappedMedia = null; public function __construct(private readonly PDO $pdo) { @@ -158,13 +158,25 @@ final class OrdersRepository a.city AS buyer_city, o.external_carrier_id, o.external_payment_type_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, - (SELECT COUNT(*) FROM order_documents od WHERE od.order_id = o.id) AS documents_count + COALESCE(oi_agg.items_count, 0) AS items_count, + COALESCE(oi_agg.items_qty, 0) AS items_qty, + COALESCE(sh_agg.shipments_count, 0) AS shipments_count, + COALESCE(od_agg.documents_count, 0) AS documents_count FROM orders o LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer" - LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code' + LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code + LEFT JOIN ( + SELECT order_id, COUNT(*) AS items_count, COALESCE(SUM(quantity), 0) AS items_qty + FROM order_items GROUP BY order_id + ) oi_agg ON oi_agg.order_id = o.id + LEFT JOIN ( + SELECT order_id, COUNT(*) AS shipments_count + FROM order_shipments GROUP BY order_id + ) sh_agg ON sh_agg.order_id = o.id + LEFT JOIN ( + SELECT order_id, COUNT(*) AS documents_count + FROM order_documents GROUP BY order_id + ) od_agg ON od_agg.order_id = o.id' . $whereSql . ' ORDER BY ' . $sortColumn . ' ' . $sortDir . ' LIMIT :limit OFFSET :offset'; @@ -646,8 +658,8 @@ final class OrdersRepository private function canResolveMappedMedia(): bool { - if ($this->supportsMappedMedia !== null) { - return $this->supportsMappedMedia; + if (self::$supportsMappedMedia !== null) { + return self::$supportsMappedMedia; } try { @@ -682,12 +694,12 @@ final class OrdersRepository $stmt->execute($params); $count = (int) $stmt->fetchColumn(); - $this->supportsMappedMedia = ($count === count($requiredColumns)); + self::$supportsMappedMedia = ($count === count($requiredColumns)); } catch (Throwable) { - $this->supportsMappedMedia = false; + self::$supportsMappedMedia = false; } - return $this->supportsMappedMedia; + return self::$supportsMappedMedia; } /** diff --git a/src/Modules/Settings/AllegroApiClient.php b/src/Modules/Settings/AllegroApiClient.php index 3315bcd..2bf0ccc 100644 --- a/src/Modules/Settings/AllegroApiClient.php +++ b/src/Modules/Settings/AllegroApiClient.php @@ -146,6 +146,44 @@ final class AllegroApiClient return $this->postJson($url, $accessToken, $body); } + private function getCaBundlePath(): ?string + { + $envPath = (string) ($_ENV['CURL_CA_BUNDLE_PATH'] ?? ''); + if ($envPath !== '' && is_file($envPath)) { + return $envPath; + } + $iniPath = (string) ini_get('curl.cainfo'); + if ($iniPath !== '' && is_file($iniPath)) { + return $iniPath; + } + $candidates = [ + '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 (is_file($path)) { + return $path; + } + } + return null; + } + + /** + * @param array $opts + * @return array + */ + private function withSslOptions(array $opts): array + { + $opts[CURLOPT_SSL_VERIFYPEER] = true; + $opts[CURLOPT_SSL_VERIFYHOST] = 2; + $caPath = $this->getCaBundlePath(); + if ($caPath !== null) { + $opts[CURLOPT_CAINFO] = $caPath; + } + return $opts; + } + private function apiBaseUrl(string $environment): string { return trim(strtolower($environment)) === 'production' @@ -166,7 +204,7 @@ final class AllegroApiClient throw new AllegroApiException('Nie udalo sie zainicjowac polaczenia z API Allegro.'); } - curl_setopt_array($ch, [ + curl_setopt_array($ch, $this->withSslOptions([ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $jsonBody, @@ -177,7 +215,7 @@ final class AllegroApiClient 'Content-Type: application/vnd.allegro.public.v1+json', 'Authorization: Bearer ' . $accessToken, ], - ]); + ])); $responseBody = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); @@ -230,7 +268,7 @@ final class AllegroApiClient throw new AllegroApiException('Nie udalo sie zainicjowac polaczenia z API Allegro.'); } - curl_setopt_array($ch, [ + curl_setopt_array($ch, $this->withSslOptions([ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $jsonBody, @@ -241,7 +279,7 @@ final class AllegroApiClient 'Content-Type: application/vnd.allegro.public.v1+json', 'Authorization: Bearer ' . $accessToken, ], - ]); + ])); $responseBody = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); @@ -279,7 +317,7 @@ final class AllegroApiClient throw new AllegroApiException('Nie udalo sie zainicjowac polaczenia z API Allegro.'); } - curl_setopt_array($ch, [ + curl_setopt_array($ch, $this->withSslOptions([ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPGET => true, CURLOPT_TIMEOUT => 30, @@ -288,7 +326,7 @@ final class AllegroApiClient 'Accept: application/vnd.allegro.public.v1+json', 'Authorization: Bearer ' . $accessToken, ], - ]); + ])); $responseBody = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); diff --git a/src/Modules/Settings/AllegroOAuthClient.php b/src/Modules/Settings/AllegroOAuthClient.php index 19d15c6..0a8398b 100644 --- a/src/Modules/Settings/AllegroOAuthClient.php +++ b/src/Modules/Settings/AllegroOAuthClient.php @@ -126,6 +126,29 @@ final class AllegroOAuthClient return trim(strtolower($environment)) === 'production' ? 'production' : 'sandbox'; } + private function getCaBundlePath(): ?string + { + $envPath = (string) ($_ENV['CURL_CA_BUNDLE_PATH'] ?? ''); + if ($envPath !== '' && is_file($envPath)) { + return $envPath; + } + $iniPath = (string) ini_get('curl.cainfo'); + if ($iniPath !== '' && is_file($iniPath)) { + return $iniPath; + } + $candidates = [ + '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 (is_file($path)) { + return $path; + } + } + return null; + } + /** * @param array $formData * @return array @@ -141,18 +164,25 @@ final class AllegroOAuthClient throw new AllegroOAuthException('Nie udalo sie zainicjowac polaczenia OAuth z Allegro.'); } - curl_setopt_array($ch, [ + $sslOpts = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_TIMEOUT => 20, CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => [ 'Accept: application/json', 'Content-Type: application/x-www-form-urlencoded', 'Authorization: Basic ' . base64_encode($clientId . ':' . $clientSecret), ], CURLOPT_POSTFIELDS => http_build_query($formData), - ]); + ]; + $caPath = $this->getCaBundlePath(); + if ($caPath !== null) { + $sslOpts[CURLOPT_CAINFO] = $caPath; + } + curl_setopt_array($ch, $sslOpts); $responseBody = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); diff --git a/src/Modules/Settings/AllegroStatusSyncService.php b/src/Modules/Settings/AllegroStatusSyncService.php index d993f38..e623097 100644 --- a/src/Modules/Settings/AllegroStatusSyncService.php +++ b/src/Modules/Settings/AllegroStatusSyncService.php @@ -38,7 +38,7 @@ final class AllegroStatusSyncService if ($direction === self::DIRECTION_ORDERPRO_TO_ALLEGRO) { return [ - 'ok' => true, + 'ok' => false, 'direction' => $direction, 'processed' => 0, 'message' => 'Kierunek orderPRO -> Allegro nie jest jeszcze wdrozony.', diff --git a/src/Modules/Settings/ApaczkaApiClient.php b/src/Modules/Settings/ApaczkaApiClient.php index d16574f..7977975 100644 --- a/src/Modules/Settings/ApaczkaApiClient.php +++ b/src/Modules/Settings/ApaczkaApiClient.php @@ -9,6 +9,29 @@ final class ApaczkaApiClient { private const API_BASE_URL = 'https://www.apaczka.pl/api/v2'; + private function getCaBundlePath(): ?string + { + $envPath = (string) ($_ENV['CURL_CA_BUNDLE_PATH'] ?? ''); + if ($envPath !== '' && is_file($envPath)) { + return $envPath; + } + $iniPath = (string) ini_get('curl.cainfo'); + if ($iniPath !== '' && is_file($iniPath)) { + return $iniPath; + } + $candidates = [ + '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 (is_file($path)) { + return $path; + } + } + return null; + } + /** * @return array> */ @@ -180,18 +203,25 @@ final class ApaczkaApiClient throw new ApaczkaApiException('Nie udalo sie zainicjowac polaczenia z API Apaczka.'); } - curl_setopt_array($ch, [ + $sslOpts = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query($payload), CURLOPT_TIMEOUT => 30, CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => [ 'Accept: application/json', 'Content-Type: application/x-www-form-urlencoded', 'User-Agent: orderPRO/1.0', ], - ]); + ]; + $caPath = $this->getCaBundlePath(); + if ($caPath !== null) { + $sslOpts[CURLOPT_CAINFO] = $caPath; + } + curl_setopt_array($ch, $sslOpts); $rawBody = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); diff --git a/src/Modules/Settings/ShopproApiClient.php b/src/Modules/Settings/ShopproApiClient.php index 59bda76..0109498 100644 --- a/src/Modules/Settings/ShopproApiClient.php +++ b/src/Modules/Settings/ShopproApiClient.php @@ -5,6 +5,29 @@ namespace App\Modules\Settings; final class ShopproApiClient { + private function getCaBundlePath(): ?string + { + $envPath = (string) ($_ENV['CURL_CA_BUNDLE_PATH'] ?? ''); + if ($envPath !== '' && is_file($envPath)) { + return $envPath; + } + $iniPath = (string) ini_get('curl.cainfo'); + if ($iniPath !== '' && is_file($iniPath)) { + return $iniPath; + } + $candidates = [ + '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 (is_file($path)) { + return $path; + } + } + return null; + } + /** * @return array{ok:bool,http_code:int|null,message:string,items:array>,total:int,page:int,per_page:int} */ @@ -193,15 +216,22 @@ final class ShopproApiClient ]; } - curl_setopt_array($curl, [ + $sslOpts = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => max(1, min(120, $timeoutSeconds)), CURLOPT_CONNECTTIMEOUT => max(1, min(120, $timeoutSeconds)), + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => [ 'Accept: application/json', 'X-Api-Key: ' . $apiKey, ], - ]); + ]; + $caPath = $this->getCaBundlePath(); + if ($caPath !== null) { + $sslOpts[CURLOPT_CAINFO] = $caPath; + } + curl_setopt_array($curl, $sslOpts); $body = curl_exec($curl); $httpCode = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);