Add Orders and Order Status repositories with pagination and management features

- Implemented OrdersRepository for handling order data with pagination, filtering, and sorting capabilities.
- Added methods for retrieving order status options, quick stats, and detailed order information.
- Created OrderStatusRepository for managing order status groups and statuses, including CRUD operations and sorting.
- Introduced a bootstrap file for test environment setup and autoloading.
This commit is contained in:
2026-03-03 01:32:28 +01:00
parent d1576bc4ab
commit c489891d15
106 changed files with 11669 additions and 5091 deletions

View File

@@ -9,6 +9,8 @@ CRON_WEB_LIMIT=5
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
# Tylko techniczne operacje agenta (np. reczne migracje), nie dla runtime aplikacji.
DB_HOST_REMOTE=
DB_PORT=3306
DB_DATABASE=orderpro
DB_USERNAME=root

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,13 @@
## Utrwalanie stalych wymagan
- Trwale wymagania techniczne zapisuj w tym pliku (`AGENTS.md`) w root projektu.
- Dla zmiennych srodowiskowych utrzymuj tez wpisy w `.env.example`.
- Utrzymuj aktualny opis schematu bazy danych w `DOCS/DB_SCHEMA.md` (aktualizacja przy kazdej zmianie migracji/schematu).
- Dokumentacje techniczna utrzymuj w folderze `DOCS`:
- `DOCS/DB_SCHEMA.md` - aktualny schemat bazy danych (aktualizacja przy kazdej zmianie migracji/schematu),
- `DOCS/ARCHITECTURE.md` - struktura klas, metod, modulow i przeplywow,
- `DOCS/TECH_CHANGELOG.md` - chronologiczny log zmian technicznych (co i dlaczego).
- Przy kazdej nowej funkcji lub zmianie:
- zaktualizuj odpowiednie sekcje w `DOCS/DB_SCHEMA.md`, `DOCS/ARCHITECTURE.md`, `DOCS/TECH_CHANGELOG.md`,
- opisz nowe tabele/kolumny/indeksy/FK, nowe klasy/metody oraz zmiany kontraktow API.
## Alerty i potwierdzenia UI
- W aplikacji uzywaj modulu `resources/modules/jquery-alerts` (build do `public/assets/js/modules/jquery-alerts.js` i `public/assets/css/modules/jquery-alerts.css`).
@@ -17,3 +23,13 @@
## Style frontendu
- Nie trzymaj styli CSS w plikach widokow (`resources/views/...`).
- Wszystkie style umieszczaj w plikach SCSS (`resources/scss/...`) i buduj do `public/assets/css/...`.
- Interfejs ma byc kompaktowy: preferuj mniejsze odstepy i gestszy uklad, tak aby pokazywac jak najwiecej informacji na jednym ekranie bez przewijania.
## Reuzywalnosc UI
- Nie powielaj kodu takich samych elementow widoku.
- Jezeli ten sam blok UI wystepuje w wiecej niz jednym miejscu, wydziel go do wspolnego komponentu (np. `resources/views/components/...`) i uzywaj ponownie.
- Zmiany wspolnego komponentu musza byc propagowane do wszystkich miejsc uzycia.
## Srodowisko lokalne (Windows)
- Komenda `php` jest dostepna z instalacji XAMPP (`C:\xampp\php\php.exe`).
- Jezeli `php` nie jest widoczne w terminalu, dodaj `C:\xampp\php` do zmiennej `PATH` (User).

View File

@@ -1,517 +0,0 @@
# shopPRO REST API
REST API do integracji z ordersPRO i innymi systemami zewnetrznymi.
## Autentykacja
Kazde zapytanie wymaga headera `X-Api-Key` z kluczem API.
```
X-Api-Key: {klucz_api}
```
Klucz przechowywany jest w `pp_settings` jako parametr `api_key`. API jest stateless (bez sesji).
## Format odpowiedzi
### Sukces (HTTP 200)
```json
{
"status": "ok",
"data": { ... }
}
```
### Blad
```json
{
"status": "error",
"code": "UNAUTHORIZED",
"message": "Invalid or missing API key"
}
```
Kody bledow:
| Kod | HTTP | Opis |
|-----|------|------|
| `UNAUTHORIZED` | 401 | Brak lub nieprawidlowy klucz API |
| `BAD_REQUEST` | 400 | Brakujace lub niepoprawne parametry |
| `NOT_FOUND` | 404 | Nie znaleziono zasobu/endpointu/akcji |
| `METHOD_NOT_ALLOWED` | 405 | Nieprawidlowa metoda HTTP |
| `INTERNAL_ERROR` | 500 | Blad wewnetrzny serwera |
## Endpointy
### Zamowienia
#### Lista zamowien
```
GET api.php?endpoint=orders&action=list
```
Parametry filtrowania (opcjonalne):
| Parametr | Typ | Opis |
|----------|-----|------|
| `status` | int | Filtruj po statusie zamowienia |
| `paid` | int (0/1) | Filtruj po statusie platnosci |
| `date_from` | date (YYYY-MM-DD) | Zamowienia od daty |
| `date_to` | date (YYYY-MM-DD) | Zamowienia do daty |
| `updated_since` | datetime (YYYY-MM-DD HH:MM:SS) | Zamowienia zmodyfikowane od podanej daty (klucz do pollingu) |
| `number` | string | Szukaj po numerze zamowienia |
| `client` | string | Szukaj po imieniu, nazwisku lub emailu klienta |
| `page` | int | Numer strony (domyslnie 1) |
| `per_page` | int | Wynikow na strone (domyslnie 50, max 100) |
Odpowiedz:
```json
{
"status": "ok",
"data": {
"items": [
{
"id": 42,
"number": "2026/02/001",
"date_order": "2026-02-19 10:30:00",
"updated_at": "2026-02-19 12:00:00",
"status": 4,
"paid": 1,
"client_name": "Jan",
"client_surname": "Kowalski",
"client_email": "jan@example.com",
"client_phone": "111222333",
"client_street": "Testowa 1",
"client_postal_code": "00-000",
"client_city": "Warszawa",
"firm_name": null,
"firm_nip": null,
"transport": "Kurier DPD",
"transport_cost": 15.00,
"payment_method": "Przelew bankowy",
"summary": 150.00
}
],
"total": 1,
"page": 1,
"per_page": 50
}
}
```
#### Szczegoly zamowienia
```
GET api.php?endpoint=orders&action=get&id={order_id}
```
Zwraca pelne dane zamowienia z produktami i historia statusow.
#### Zmiana statusu zamowienia
```
PUT api.php?endpoint=orders&action=change_status&id={order_id}
Content-Type: application/json
{
"status_id": 5,
"send_email": true
}
```
Odpowiedz:
```json
{
"status": "ok",
"data": {
"order_id": 42,
"status_id": 5,
"changed": true
}
}
```
#### Oznacz jako oplacone
```
PUT api.php?endpoint=orders&action=set_paid&id={order_id}
```
Opcjonalnie w body: `{"send_email": true}`
#### Oznacz jako nieoplacone
```
PUT api.php?endpoint=orders&action=set_unpaid&id={order_id}
```
### Produkty
#### Lista produktow
```
GET api.php?endpoint=products&action=list
```
Parametry filtrowania (opcjonalne):
| Parametr | Typ | Opis |
|----------|-----|------|
| `search` | string | Szukaj po nazwie, EAN lub SKU |
| `status` | int (0/1) | Filtruj po statusie (1 = aktywny, 0 = nieaktywny) |
| `promoted` | int (0/1) | Filtruj po promocji |
| `attribute_{id}` | int | Filtruj po atrybucie — `attribute_1=3` oznacza atrybut 1 = wartosc 3 (wiele filtrow AND) |
| `sort` | string | Sortuj po: id, name, price_brutto, status, promoted, quantity (domyslnie id) |
| `sort_dir` | string | Kierunek: ASC lub DESC (domyslnie DESC) |
| `page` | int | Numer strony (domyslnie 1) |
| `per_page` | int | Wynikow na strone (domyslnie 50, max 100) |
Odpowiedz:
```json
{
"status": "ok",
"data": {
"items": [
{
"id": 1,
"sku": "PROD-001",
"ean": "5901234123457",
"name": "Produkt testowy",
"price_brutto": 99.99,
"price_brutto_promo": null,
"price_netto": 81.29,
"price_netto_promo": null,
"quantity": 10,
"status": 1,
"promoted": 0,
"vat": 23,
"weight": 0.5,
"main_image": "product1.jpg",
"date_add": "2026-01-15 10:00:00",
"date_modify": "2026-02-19 12:00:00"
}
],
"total": 1,
"page": 1,
"per_page": 50
}
}
```
#### Szczegoly produktu
```
GET api.php?endpoint=products&action=get&id={product_id}
```
Zwraca pelne dane produktu z jezykami, zdjeciami, kategoriami i atrybutami.
Odpowiedz:
```json
{
"status": "ok",
"data": {
"id": 1,
"sku": "PROD-001",
"ean": "5901234123457",
"price_brutto": 99.99,
"price_brutto_promo": null,
"price_netto": 81.29,
"price_netto_promo": null,
"quantity": 10,
"status": 1,
"promoted": 0,
"vat": 23,
"weight": 0.5,
"stock_0_buy": 0,
"custom_label_0": null,
"set_id": null,
"product_unit_id": 1,
"producer_id": 3,
"date_add": "2026-01-15 10:00:00",
"date_modify": "2026-02-19 12:00:00",
"languages": {
"pl": {
"name": "Produkt testowy",
"short_description": "Krotki opis",
"description": "<p>Pelny opis produktu</p>",
"meta_description": null,
"meta_keywords": null,
"meta_title": null,
"seo_link": "produkt-testowy",
"copy_from": null,
"warehouse_message_zero": null,
"warehouse_message_nonzero": null,
"tab_name_1": null,
"tab_description_1": null,
"tab_name_2": null,
"tab_description_2": null,
"canonical": null
}
},
"images": [
{"id": 1, "src": "product1.jpg", "alt": "Zdjecie produktu"}
],
"categories": [1, 5],
"attributes": [
{
"attribute_id": 1,
"attribute_type": 1,
"attribute_names": {"pl": "Kolor", "en": "Color"},
"value_id": 3,
"value_names": {"pl": "Czerwony", "en": "Red"}
}
],
"variants": [
{
"id": 101,
"permutation_hash": "1-3|2-5",
"sku": "PROD-001-RED-L",
"ean": null,
"price_brutto": 109.99,
"price_brutto_promo": null,
"price_netto": 89.42,
"price_netto_promo": null,
"quantity": 5,
"stock_0_buy": 0,
"weight": 0.5,
"status": 1,
"attributes": [
{"attribute_id": 1, "attribute_names": {"pl": "Kolor"}, "value_id": 3, "value_names": {"pl": "Czerwony"}},
{"attribute_id": 2, "attribute_names": {"pl": "Rozmiar"}, "value_id": 5, "value_names": {"pl": "L"}}
]
}
]
}
}
```
#### Tworzenie produktu
```
POST api.php?endpoint=products&action=create
Content-Type: application/json
{
"price_brutto": 99.99,
"vat": 23,
"quantity": 10,
"status": 1,
"sku": "PROD-001",
"ean": "5901234123457",
"weight": 0.5,
"languages": {
"pl": {
"name": "Nowy produkt",
"description": "<p>Opis produktu</p>"
}
},
"categories": [1, 5],
"products_related": [10, 20]
}
```
Wymagane: `languages` (min. 1 jezyk z `name`) oraz `price_brutto`.
Odpowiedz (HTTP 201):
```json
{
"status": "ok",
"data": {
"id": 42
}
}
```
#### Aktualizacja produktu
```
PUT api.php?endpoint=products&action=update&id={product_id}
Content-Type: application/json
{
"price_brutto": 129.99,
"status": 1,
"languages": {
"pl": {
"name": "Zaktualizowana nazwa"
}
}
}
```
Partial update — wystarczy przeslac tylko zmienione pola. Pola nieprzeslane zachowuja aktualna wartosc.
Odpowiedz: pelne dane produktu (jak w `get`).
### Warianty produktow
#### Lista wariantow produktu
```
GET api.php?endpoint=products&action=variants&id={product_id}
```
Zwraca warianty produktu nadrzednego wraz z dostepnymi atrybutami.
Odpowiedz:
```json
{
"status": "ok",
"data": {
"product_id": 1,
"available_attributes": [
{
"id": 1,
"type": 1,
"status": 1,
"names": {"pl": "Kolor", "en": "Color"},
"values": [
{"id": 3, "names": {"pl": "Czerwony", "en": "Red"}, "is_default": 0, "impact_on_the_price": null},
{"id": 4, "names": {"pl": "Niebieski", "en": "Blue"}, "is_default": 0, "impact_on_the_price": 10.0}
]
}
],
"variants": [
{
"id": 101,
"permutation_hash": "1-3",
"sku": "PROD-001-RED",
"ean": null,
"price_brutto": 109.99,
"price_brutto_promo": null,
"price_netto": 89.42,
"price_netto_promo": null,
"quantity": 5,
"stock_0_buy": 0,
"weight": 0.5,
"status": 1,
"attributes": [
{"attribute_id": 1, "attribute_names": {"pl": "Kolor"}, "value_id": 3, "value_names": {"pl": "Czerwony"}}
]
}
]
}
}
```
#### Tworzenie wariantu
```
POST api.php?endpoint=products&action=create_variant&id={product_id}
Content-Type: application/json
{
"attributes": {"1": 3, "2": 5},
"sku": "PROD-001-RED-L",
"ean": "5901234123458",
"price_brutto": 109.99,
"quantity": 5,
"weight": 0.5
}
```
Wymagane: `attributes` (mapa attribute_id -> value_id, min. 1). Kombinacja atrybutow musi byc unikalna.
Odpowiedz (HTTP 201): pelne dane wariantu.
#### Aktualizacja wariantu
```
PUT api.php?endpoint=products&action=update_variant&id={variant_id}
Content-Type: application/json
{
"sku": "PROD-001-RED-XL",
"price_brutto": 119.99,
"quantity": 3
}
```
Partial update — mozna zmienic: sku, ean, price_brutto, price_netto, price_brutto_promo, price_netto_promo, quantity, stock_0_buy, weight, status.
Odpowiedz: pelne dane wariantu.
#### Usuwanie wariantu
```
DELETE api.php?endpoint=products&action=delete_variant&id={variant_id}
```
Odpowiedz:
```json
{
"status": "ok",
"data": {"id": 101, "deleted": true}
}
```
### Slowniki
#### Lista statusow zamowien
```
GET api.php?endpoint=dictionaries&action=statuses
```
Odpowiedz:
```json
{
"status": "ok",
"data": [
{"id": 0, "name": "Nowe"},
{"id": 1, "name": "Oplacone"},
{"id": 4, "name": "W realizacji"},
{"id": 6, "name": "Wyslane"}
]
}
```
#### Lista metod transportu
```
GET api.php?endpoint=dictionaries&action=transports
```
#### Lista metod platnosci
```
GET api.php?endpoint=dictionaries&action=payment_methods
```
#### Lista atrybutow
```
GET api.php?endpoint=dictionaries&action=attributes
```
Zwraca aktywne atrybuty z wartosciami i wielojezycznymi nazwami.
Odpowiedz:
```json
{
"status": "ok",
"data": [
{
"id": 1,
"type": 1,
"status": 1,
"names": {"pl": "Kolor", "en": "Color"},
"values": [
{"id": 3, "names": {"pl": "Czerwony"}, "is_default": 0, "impact_on_the_price": null},
{"id": 4, "names": {"pl": "Niebieski"}, "is_default": 1, "impact_on_the_price": 10.0}
]
}
]
}
```
## Polling
Aby pobierac tylko nowe/zmienione zamowienia, uzyj parametru `updated_since`:
```
GET api.php?endpoint=orders&action=list&updated_since=2026-02-19 12:00:00
```
Kolumna `updated_at` w `pp_shop_orders` jest aktualizowana automatycznie przy kazdej modyfikacji zamowienia (zmiana statusu, platnosci, edycja danych, tworzenie zamowienia).
## Konfiguracja
Klucz API ustawia sie w panelu admina w ustawieniach sklepu lub bezposrednio w bazie:
```sql
INSERT INTO pp_settings (param, value) VALUES ('api_key', 'twoj-klucz-api');
-- lub
UPDATE pp_settings SET value = 'twoj-klucz-api' WHERE param = 'api_key';
```
## Architektura
- Entry point: `api.php`
- Router: `\api\ApiRouter` (`autoload/api/ApiRouter.php`)
- Kontrolery: `autoload/api/Controllers/`
- `OrdersApiController` — zamowienia (5 akcji)
- `ProductsApiController` — produkty (8 akcji: list, get, create, update, variants, create_variant, update_variant, delete_variant)
- `DictionariesApiController` — slowniki (4 akcje: statuses, transports, payment_methods, attributes)

152
DOCS/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,152 @@
# Architecture
## Status
- Projekt po resecie do trybu `users-only`.
## Moduly aktywne
- `App\Modules\Auth`
- `App\Modules\Orders`
- `App\Modules\Users`
- `App\Modules\Settings`
## Routing
- `GET /login`, `POST /login`, `POST /logout`
- `GET /settings/users`, `POST /settings/users`
- `GET /orders` (redirect do `/orders/list`)
- `GET /orders/list`
- `GET /orders/{id}`
- `GET /users` (redirect do `/settings/users`)
- `POST /users` (compat route)
- `GET /settings` (redirect do `/settings/users`)
- `GET /settings/database`
- `POST /settings/database/migrate`
- `GET /settings/statuses`
- `POST /settings/status-groups`
- `POST /settings/status-groups/update`
- `POST /settings/status-groups/delete`
- `POST /settings/status-groups/reorder`
- `POST /settings/statuses/create`
- `POST /settings/statuses/update`
- `POST /settings/statuses/delete`
- `POST /settings/statuses/reorder`
- `GET /health`, `GET /` (redirect)
## Korekta logowania
- `AuthController::showLogin(Request): Response`:
- dla zalogowanego usera redirect na `/settings/users` (zamiast nieistniejacego `/dashboard`).
- `AuthController::login(Request): Response`:
- po poprawnym logowaniu redirect na `/settings/users`.
## Kluczowe klasy
- `App\Core\Application`
- `App\Modules\Auth\AuthController`
- `App\Modules\Auth\AuthService`
- `App\Modules\Orders\OrdersController`
- `App\Modules\Orders\OrdersRepository`
- `App\Modules\Settings\SettingsController`
- `App\Modules\Settings\OrderStatusRepository`
- `App\Modules\Users\UsersController`
- `App\Modules\Users\UserRepository`
## Przeplyw Zamowienia > Lista zamowien
- `GET /orders/list`:
- `OrdersController::index(Request): Response`
- pobiera dane listy przez `OrdersRepository::paginate(...)`,
- pobiera slowniki filtrow (`sourceOptions()`, `statusOptions()`), statystyki (`quickStats()`), agregaty statusow (`statusCounts()`) i konfiguracje grup/statusow (`statusPanelConfig()`),
- buduje panel statusow z grupami i licznikami (`buildStatusPanel(...)`) z linkami filtrujacymi po statusie,
- panel statusow i etykiety statusow sa zgodne z konfiguracja z `Ustawienia > Statusy` (z fallbackiem `Pozostale`),
- renderuje podglad pozycji zamowienia (nazwa, miniatura, ilosc) na bazie `order_items`,
- obsluguje modal podgladu zdjecia pozycji po kliknieciu miniatury,
- normalizuje status techniczny na etykiete biznesowa (bez kodu statusu),
- renderuje widok `resources/views/orders/list.php` i komponent tabeli `resources/views/components/table-list.php`.
- `GET /orders/{id}`:
- `OrdersController::show(Request): Response`
- pobiera szczegoly przez `OrdersRepository::findDetails(int $orderId)`, statystyke statusow przez `statusCounts()` oraz konfiguracje przez `statusPanelConfig()`,
- buduje panel statusow z grupami i licznikami (`buildStatusPanel(...)`),
- renderuje klikalne taby sekcji i przelaczanie paneli po stronie klienta (JS w `orders/show.php`),
- renderuje widok `resources/views/orders/show.php` z sekcjami:
- pozycje zamowienia,
- szczegoly zamowienia,
- platnosc i wysylka,
- adresy (`customer`, `invoice`, `delivery`),
- notatki i historia statusow.
- Sidebar ma oddzielna grupe nawigacyjna:
- `Zamowienia` -> `Lista zamowien`.
## Skrypty techniczne (CLI)
- `bin/fix_status_codes.php`
- naprawa kodow grup/statusow (transliteracja PL -> ASCII, tryb `--dry-run`, opcja `--use-remote`).
- `bin/deploy_and_seed_orders.php`
- aplikuje generyczny schema zamowien z `database/drafts/20260302_orders_schema_v1.sql`,
- seeduje dane testowe (`--count`, `--append`, `--use-remote`, `--profile=default|realistic`),
- profil `realistic` utrzymuje spojne zaleznosci miedzy:
- statusem zamowienia,
- statusem i kwota platnosci,
- obecnoscia wysylek i dokumentow,
- historia przejsc statusow (deterministyczne sciezki zamiast losowych przeskokow).
## Przeplyw Ustawienia > Statusy
- `GET /settings/statuses`:
- `SettingsController::statuses(Request): Response`
- pobiera dane przez `OrderStatusRepository::listGroups()` i `OrderStatusRepository::listStatuses()`,
- renderuje widok `resources/views/settings/statuses.php`.
- `POST /settings/status-groups`:
- `SettingsController::createStatusGroup(Request): Response`
- waliduje CSRF i dane (`name`, `color_hex`, `is_active`),
- `code` jest generowany automatycznie z `name` i nie jest edytowany z UI,
- zapisuje przez `OrderStatusRepository::createGroup(...)`.
- `POST /settings/status-groups/update`:
- `SettingsController::updateStatusGroup(Request): Response`
- waliduje istnienie grupy i aktualizuje rekord przez `updateGroup(...)`,
- `code` pozostaje bez zmian (read-only po utworzeniu).
- `POST /settings/status-groups/delete`:
- `SettingsController::deleteStatusGroup(Request): Response`
- usuwa grupe przez `deleteGroup(...)`; statusy z tej grupy usuwane sa kaskadowo (FK).
- `POST /settings/status-groups/reorder`:
- `SettingsController::reorderStatusGroups(Request): Response`
- zapisuje kolejnosc drag-and-drop grup przez `OrderStatusRepository::reorderGroups(...)`,
- endpoint jest wywolywany automatycznie po upuszczeniu elementu listy (auto-save).
- `POST /settings/statuses/create`:
- `SettingsController::createStatus(Request): Response`
- waliduje grupe, pola statusu i zapisuje przez `createStatus(...)`.
- `POST /settings/statuses/update`:
- `SettingsController::updateStatus(Request): Response`
- waliduje dane i aktualizuje status przez `updateStatus(...)`,
- `code` pozostaje bez zmian (read-only po utworzeniu).
- `POST /settings/statuses/delete`:
- `SettingsController::deleteStatus(Request): Response`
- usuwa status przez `deleteStatus(...)`.
- `POST /settings/statuses/reorder`:
- `SettingsController::reorderStatuses(Request): Response`
- zapisuje kolejnosc drag-and-drop statusow w ramach grupy przez `OrderStatusRepository::reorderStatusesByGroup(...)`,
- endpoint jest wywolywany automatycznie po upuszczeniu elementu listy (auto-save).
## Nawigacja ustawien
- Sidebar (`resources/views/layouts/app.php`) ma nowy podlink:
- `Statusy` (`/settings/statuses`).
## Przeplyw Ustawienia > Baza danych
- `GET /settings/database`:
- `SettingsController::database(Request): Response`
- pobiera `Migrator::status()`, przekazuje statystyki i liste pending migracji do widoku `resources/views/settings/database.php`.
- `POST /settings/database/migrate`:
- `SettingsController::migrate(Request): Response`
- waliduje CSRF,
- uruchamia `Migrator::runPending()`,
- zapisuje wynik do flash (`settings_success` / `settings_error`, `settings_migrate_logs`),
- wykonuje redirect do `GET /settings/database`.
## Zmiany nawigacji
- Sidebar ma teraz grupe `Ustawienia` z podlinkami:
- `Uzytkownicy` (`/settings/users`)
- `Baza danych` (`/settings/database`)
- `UsersController::index(Request): Response` ustawia:
- `activeMenu = settings`
- `activeSettings = users`
- Usunieto wewnetrzny pasek `settings-nav` z widokow podstron ustawien.
## Zasady aktualizacji
- Przy kazdej zmianie dopisz:
- nowe klasy i metody (sygnatury + odpowiedzialnosc),
- zmiany przeplywu request -> controller -> repository,
- kontrakty wejscia/wyjscia istotnych metod.

View File

@@ -1,113 +0,0 @@
# orderPRO - Backlog mikro-zadania (MVP)
Data: 2026-02-19
Legenda statusow:
- `TODO` - do zrobienia
- `DOING` - w trakcie
- `DONE` - zakonczone
- `BLOCKED` - zablokowane
## Sprint 0 - Fundament
1. `DONE` Utworzyc strukture katalogow projektu (`public`, `src`, `config`, `database`, `storage`, `bin`).
2. `DONE` Dodac `composer.json` z podstawowymi bibliotekami.
3. `DONE` Przygotowac `public/index.php` jako front controller.
4. `DONE` Dodac prosty router i testowa trase `/health`.
5. `DONE` Dodac loader konfiguracji `.env`.
6. `TODO` Podlaczyc MySQL (polaczenie + test zapytania).
7. `TODO` Utworzyc migracje tabeli `users`.
8. `TODO` Dodac seed pierwszego uzytkownika admin.
9. `TODO` Przygotowac bazowy layout HTML panelu (`header`, `sidebar`, `content`).
10. `DONE` Przygotowac wyglad strony logowania (sam widok).
11. `DONE` Dodac formularz logowania (email + haslo + submit).
12. `DONE` Dodac endpoint POST logowania i walidacje danych.
13. `DONE` Dodac sesje uzytkownika po poprawnym logowaniu.
14. `DONE` Dodac middleware sprawdzajace zalogowanie.
15. `DONE` Dodac wylogowanie (`POST /logout`).
16. `DONE` Dodac komunikaty bledow logowania w widoku.
17. `DONE` Dodac CSRF token do formularzy.
18. `DONE` Dodac podstawowe logowanie bledow do pliku.
19. `DONE` Przeniesc teksty UI do systemu tlumaczen (`resources/lang/pl.php` + helper `$t()`).
20. `DONE` Dodac konfiguracje locale i podlaczyc translator do widokow i kontrolerow.
21. `DONE` Przeniesc style do SCSS i uruchomic kompilacje/minifikacje do `public/assets/css`.
22. `DONE` Dodac skrypty frontendowe do budowy styli (`npm run build:css`, `npm run watch:css`).
23. `DONE` Ujednolicic design panelu i logowania pod styl adsPRO.
## Sprint 1 - Zamowienia z wlasnego sklepu
24. `TODO` Utworzyc migracje `orders`, `order_items`, `order_addresses`.
25. `TODO` Dodac encje/repozytoria dla zamowien.
26. `TODO` Dodac ekran listy zamowien (`/orders`) z paginacja.
27. `TODO` Dodac ekran szczegolow zamowienia (`/orders/{id}`).
28. `TODO` Dodac zmiane statusu zamowienia przyciskiem.
29. `TODO` Dodac tabele `order_status_history`.
30. `TODO` Zapisywac historie kazdej zmiany statusu.
31. `TODO` Dodac konfiguracje kanalu "wlasny sklep" w tabeli `channels`.
32. `TODO` Dodac klienta API wlasnego sklepu.
33. `TODO` Dodac importer zamowien (reczne uruchomienie z panelu).
34. `TODO` Dodac deduplikacje zamowien po `external_order_id`.
35. `TODO` Dodac cron do cyklicznego importu.
## Sprint 2 - Allegro
36. `TODO` Utworzyc konfiguracje Allegro w `channels`.
37. `TODO` Dodac ekran podpiecia konta Allegro (OAuth start/callback).
38. `TODO` Zapisac i odswiezac tokeny Allegro.
39. `TODO` Dodac importer zamowien Allegro.
40. `TODO` Dodac mapowanie statusow Allegro -> statusy lokalne.
41. `TODO` Dodac logowanie bledow API Allegro do `sync_errors`.
## Sprint 3 - Apaczka
42. `TODO` Utworzyc migracje `shipments`, `shipment_labels`.
43. `TODO` Dodac konfiguracje API Apaczka.
44. `TODO` Dodac przycisk "Utworz przesylke" na szczegolach zamowienia.
45. `TODO` Dodac wysylke danych paczki do Apaczka.
46. `TODO` Zapisac numer tracking i status utworzenia.
47. `TODO` Dodac pobieranie etykiety PDF.
48. `TODO` Dodac cron odswiezajacy statusy przesylek.
## Sprint 4 - Dokumenty i magazyn uproszczony
49. `TODO` Utworzyc migracje `documents`, `inventory_items`, `inventory_reservations`.
50. `TODO` Dodac integracje z systemem dokumentow (API zewnetrzne).
51. `TODO` Dodac przycisk "Wystaw dokument" na szczegolach zamowienia.
52. `TODO` Zapisac identyfikator/link dokumentu z systemu zewnetrznego.
53. `TODO` Dodac rezerwacje stanu po imporcie zamowienia.
54. `TODO` Dodac zwolnienie rezerwacji po anulowaniu zamowienia.
55. `TODO` Dodac prosty widok stanow magazynowych.
## Sprint 5 - Stabilizacja
56. `TODO` Dodac tabele `jobs` i `failed_jobs`.
57. `TODO` Przeniesc ciezsze operacje integracyjne do jobow.
58. `TODO` Dodac retry z rosnacym opoznieniem.
59. `TODO` Dodac panel "Bledy synchronizacji".
60. `TODO` Dodac testy najwazniejszych flow (logowanie, import, przesylka).
61. `TODO` Dodac skrypt backupu bazy (cron nocny).
## Nastepne zadanie (start)
1. `TODO` Podlaczyc MySQL (polaczenie + test zapytania):
- klasa polaczenia PDO,
- konfiguracja z `.env`,
- prosty endpoint kontrolny DB.
## Sprint 1.5 - Modul produktow (bez importu/eksportu)
62. `DONE` Utworzyc migracje tabel: `products`, `product_translations`, `product_images`, `product_categories`.
63. `DONE` Utworzyc migracje tabel: `product_variants`, `product_variant_attributes`.
64. `DONE` Utworzyc migracje tabel slownikowych: `attributes`, `attribute_translations`, `attribute_values`, `attribute_value_translations`.
65. `DONE` Utworzyc migracje tabel pomocniczych: `product_change_log`, `sales_channels`, `product_channel_map`.
66. `DONE` Dodac repozytoria i serwis domenowy dla produktow.
67. `DONE` Dodac walidator produktow i wariantow (nazwa, cena, SKU produktu i wariantu, EAN, kombinacje atrybutow).
68. `DONE` Dodac ekran listy produktow (`/products`) z filtrami i paginacja.
69. `DONE` Dodac ekran tworzenia produktu (`/products/create`) i zapis (`POST /products`).
70. `DONE` Dodac ekran szczegolow i edycji produktu (`/products/edit?id={id}`).
71. `TODO` Dodac obsluge wariantow (dodaj/edytuj) na szczegolach produktu + konwersja simple <-> variant_parent.
72. `TODO` Dodac sekcje atrybutow i wartosci (lista + dodawanie).
73. `DONE` Dodac wpisy do `product_change_log` przy zmianach krytycznych.
74. `DONE` Dodac pozycje "Produkty" do nawigacji + tlumaczenia `resources/lang/pl.php`.
75. `DONE` Dodac dwukierunkowe przeliczanie cen brutto/netto (UI + backend) na podstawie VAT.
76. `TODO` Dodac upload i zapis zdjec produktu na serwerze orderPRO.
77. `TODO` Przeprowadzic test manualny flow: create/edit/list/filter/variant.

View File

@@ -1,27 +0,0 @@
# Kolejka Cron (DB)
## Cel
- Zadania cron sa zapisywane w bazie (`cron_jobs`) i planowane przez harmonogram (`cron_schedules`).
- Aktualnie domyslnie dziala zadanie: `product_links_health_check` (co 7 dni).
## Tabele
- `cron_jobs` - kolejka zadan z priorytetem, retry i backoff.
- `cron_schedules` - definicje cyklicznych zadan.
- `product_link_alerts` - alerty dla nieistniejacych powiazan produktu.
## Uruchamianie
- Jednorazowo: `php bin/cron.php`
- Z limitem batcha: `php bin/cron.php --limit=50`
- Z panelu (`Ustawienia -> Cron`) mozna wlaczyc uruchamianie workera podczas requestow HTTP.
## Zalecenie dla systemowego crona
- Uruchamiaj `php /sciezka/do/orderPRO/bin/cron.php` co 1-5 minut.
- Harmonogram 7-dniowy jest liczony przez `cron_schedules.next_run_at`, wiec sam worker powinien byc uruchamiany regularnie.
## Jak dziala `product_links_health_check`
1. Pobiera aktywne integracje `shoppro` z API key.
2. Odswieza cache ofert (`channel_offers`) przez import API.
3. Czyści nieaktualne rekordy ofert z cache.
4. Weryfikuje aktywne powiazania `product_channel_map`.
5. Dla brakujacych powiazan ustawia alert `missing_remote_link`.
6. Dla przywroconych powiazan zamyka alert.

View File

@@ -1,119 +1,79 @@
# Struktura bazy danych (orderPRO)
# DB Schema
## Cel pliku
- Ten dokument opisuje aktualny schemat bazy danych na podstawie migracji w `database/migrations`.
- Aktualizuj ten plik przy każdej zmianie schematu (nowa tabela, kolumna, indeks, klucz obcy).
## Status
- Projekt po resecie do trybu `users-only`.
- Aktualizuj ten plik przy kazdej zmianie migracji/schematu.
- 2026-03-02: Przywrocenie UI `Ustawienia > Baza danych` nie wprowadza zmian w schemacie.
- 2026-03-02: Dodano tabele statusow (grupy + statusy) dla nowej zakladki `Ustawienia > Statusy`.
- 2026-03-02: Przygotowano draft generycznego schematu zamowien (bez aktywnej migracji) w `database/drafts/20260302_orders_schema_v1.sql`.
- 2026-03-03: Wdrozono generyczne tabele zamowien na bazie docelowej skryptem `bin/deploy_and_seed_orders.php` (bez migratora SQL).
- 2026-03-03: Dodano UI `Zamowienia > Lista zamowien` - bez zmian schematu (wykorzystuje istniejace tabele domeny zamowien).
- 2026-03-03: Dodano UI `Zamowienia > Szczegoly zamowienia` (`GET /orders/{id}`) - bez zmian schematu.
## Tabele i przeznaczenie
## Tabele
### `users`
- Uzytkownicy panelu.
- Klucz unikalny: `email`.
### `products`
- Glowna tabela produktow lokalnych.
- Najwazniejsze pola: `type`, `sku`, `ean`, `status`, `promoted`, `vat`, `weight`, `price_*`, `quantity`.
- Pola shopPRO: `new_to_date`, `additional_message`, `additional_message_required`, `additional_message_text`.
- Dodatkowe: `producer_id`, `producer_name`, `product_unit_id`, `custom_fields_json`.
- Soft delete: `deleted_at`.
### `order_status_groups`
- Grupy statusow zamowien zarzadzane z UI.
- Kolumny:
- `id` (PK, int unsigned, AI),
- `name` (varchar 120),
- `code` (varchar 64, UNIQUE),
- `color_hex` (char 7, domyslnie `#64748b`),
- `sort_order` (int, domyslnie `0`),
- `is_active` (tinyint(1), domyslnie `1`),
- `created_at`, `updated_at`.
- Indeksy:
- `order_status_groups_code_unique` (UNIQUE: `code`),
- `order_status_groups_sort_order_idx` (`sort_order`).
### `product_translations`
- Globalne tresci produktu per jezyk (`lang`).
- Najwazniejsze pola: `name`, `short_description`, `description`, SEO, `security_information`.
- Unikalnosc: `(product_id, lang)`.
### `order_statuses`
- Statusy przypisane do grup statusow.
- Kolumny:
- `id` (PK, int unsigned, AI),
- `group_id` (FK -> `order_status_groups.id`),
- `name` (varchar 120),
- `code` (varchar 64, UNIQUE),
- `sort_order` (int, domyslnie `0`),
- `is_active` (tinyint(1), domyslnie `1`),
- `created_at`, `updated_at`.
- Indeksy:
- `order_statuses_code_unique` (UNIQUE: `code`),
- `order_statuses_group_sort_idx` (`group_id`, `sort_order`, `id`).
- Klucze obce:
- `order_statuses_group_fk`: `group_id` -> `order_status_groups.id` (`ON DELETE CASCADE`, `ON UPDATE CASCADE`).
### `product_integration_translations`
- Nadpisania tresci produktu per integracja shopPRO.
- Pola: `name`, `short_description`, `description` (NULL = fallback do `product_translations`).
- Unikalnosc: `(product_id, integration_id)`.
### Domena zamowien (generyczna)
- Wdrozone tabele:
- `orders`
- `order_addresses`
- `order_items`
- `order_payments`
- `order_shipments`
- `order_documents`
- `order_notes`
- `order_status_history`
- `order_tags_dict`
- `order_tag_links`
- `integration_order_sync_state`
- Charakterystyka:
- schema neutralna wzgledem dostawcy API (pola `source_*`, `external_*`),
- kolekcje zamowienia rozdzielone na osobne tabele 1:N,
- `payload_json` dostepne dla diagnostyki/replay,
- historia zmian statusow utrzymywana w `order_status_history`.
### `product_images`
- Obrazy produktu (`storage_path`, `is_main`, `sort_order`).
## Zasady aktualizacji
- Po kazdej migracji dopisz:
- nowe/zmienione tabele i kolumny,
- indeksy i klucze obce,
- wplyw na dane i kompatybilnosc wsteczna.
### `product_categories`
- Relacja M:N produkt-kategoria (lokalna).
### `attributes`
- Definicje atrybutow wariantow.
### `attribute_translations`
- Tlumaczenia nazw atrybutow per jezyk.
### `attribute_values`
- Wartosci atrybutow (np. kolor, rozmiar), z opcjonalnym `impact_on_price`.
### `attribute_value_translations`
- Tlumaczenia wartosci atrybutow per jezyk.
### `product_variants`
- Warianty produktu.
- Najwazniejsze pola: `permutation_hash`, `sku`, `ean`, `status`, `price_*`, `weight`, `stock_0_buy`.
- Unikalnosc: `sku`, `(product_id, permutation_hash)`.
### `product_variant_attributes`
- Relacja wariant -> (atrybut, wartosc).
- Klucz glowny: `(variant_id, attribute_id)`.
### `product_change_log`
- Log zmian produktow (audyt JSON: `before_json`, `after_json`).
### `sales_channels`
- Slownik kanalow sprzedazy (`shoppro`, `allegro`, `erli`, ...).
### `product_channel_map`
- Mapowanie lokalnego produktu do kanalu/integracji i ID zewnetrznych.
- Najwazniejsze pola: `integration_id`, `external_product_id`, `external_variant_id`, `sync_state`, `link_type`, `link_status`, `confidence`.
- Pola audytowe powiazania: `linked_at`, `linked_by_user_id`, `unlinked_at`, `unlinked_by_user_id`, `sync_meta_json`.
### `channel_offers`
- Cache ofert zewnetrznych dla integracji.
- Najwazniejsze pola: `external_product_id`, `external_variant_id`, `external_offer_id`, `name` (tytul oferty), `sku`, `ean`, `offer_status`, `last_seen_at`, `payload_json`.
- Unikalnosc: `(integration_id, external_product_id, external_variant_id)`.
### `product_link_events`
- Historia zdarzen na powiazaniach (`product_channel_map`).
### `integrations`
- Konfiguracja instancji integracji (obecnie shopPRO).
- Najwazniejsze pola: `type`, `name`, `base_url`, `api_key_encrypted`, `timeout_seconds`, `is_active`.
### `integration_test_logs`
- Historia testow polaczen integracji.
### `cron_jobs`
- Kolejka jobow crona.
### `cron_schedules`
- Harmonogramy okresowych jobow.
- Aktualnie zawiera m.in.:
- `product_links_health_check`
- `shoppro_offer_titles_refresh` (odswiezanie tytulow ofert; domyslnie co 30 dni)
### `product_link_alerts`
- Alerty zdrowia powiazan produktu.
### `app_settings`
- Ustawienia aplikacyjne key-value.
- Przykładowe klucze: `cron_run_on_web`, `cron_web_limit`, `gs1_*`, `products_sku_format`.
## Relacje (skrot)
- `product_translations.product_id -> products.id`
- `product_integration_translations.product_id -> products.id`
- `product_integration_translations.integration_id -> integrations.id`
- `product_images.product_id -> products.id`
- `product_variants.product_id -> products.id`
- `product_variant_attributes.variant_id -> product_variants.id`
- `product_variant_attributes.attribute_id -> attributes.id`
- `product_variant_attributes.value_id -> attribute_values.id`
- `product_channel_map.product_id -> products.id`
- `product_channel_map.channel_id -> sales_channels.id`
- `product_channel_map.integration_id -> integrations.id`
- `channel_offers.integration_id -> integrations.id`
- `channel_offers.channel_id -> sales_channels.id`
- `product_link_events.product_channel_map_id -> product_channel_map.id`
- `product_link_alerts.product_channel_map_id -> product_channel_map.id`
## Jak utrzymywac dokument
1. Po dodaniu migracji zaktualizuj sekcje tabel/kolumn/relacji.
2. Gdy dodajesz nowe klucze do `app_settings`, dopisz je tu.
3. Przy zmianach harmonogramu crona zaktualizuj liste jobow w `cron_schedules`.
## Drafty (nieaktywne)
- `database/drafts/20260302_orders_schema_v1.sql`:
- propozycja normalizacji domeny zamowien pod integracje zewnetrzne (`orders`, `order_items`, `order_status_history`, platnosci, wysylki, dokumenty, notatki, tagi, sync-state),
- plik nie jest odpalany przez obecny migrator (`database/migrations/*.sql`).
- `bin/deploy_and_seed_orders.php`:
- techniczny skrypt wdrozeniowy, ktory aplikuje schema z draftu i opcjonalnie seeduje dane testowe.

View File

@@ -1,17 +0,0 @@
# Frontend Standards
## 1) Wspolne style UI
- Powtarzalne elementy (np. przyciski, tabele, paginacja, alerty, pola formularzy) trzymamy w:
- `resources/scss/shared/_ui-components.scss`
- Widoki maja korzystac z klas wspolnych (`btn`, `table`, `pagination`, `form-control`, `alert`) zamiast duplikowania stylu lokalnie.
## 2) Moduly JS wielokrotnego uzycia
- Kazdy modul przenoszalny trzymamy w oddzielnym folderze:
- `resources/modules/<module-name>/`
- Minimalny zestaw plikow:
- `resources/modules/<module-name>/<module-name>.js`
- `resources/modules/<module-name>/<module-name>.scss`
- Modul ma byc niezalezny od logiki projektu (brak hardcoded sciezek i zaleznosci biznesowych).
## 3) Przyklad
- Referencyjny modul: `resources/modules/jquery-alerts/`

View File

@@ -1,19 +0,0 @@
# Migracje bazy danych
## Zasada
- Kazda zmiana schematu bazy to nowy plik `.sql` w `database/migrations`.
- Pliki sa wykonywane rosnaco po nazwie.
- Wykonane migracje sa zapisywane w tabeli `migrations`.
## Nazewnictwo plikow
- Format: `YYYYMMDD_HHMMSS_opis.sql`
- Przyklad: `20260221_000001_create_users_table.sql`
## Uruchamianie
- CLI: `php bin/migrate.php` lub `composer migrate`
- Panel: `Ustawienia > Aktualizacja bazy danych > Wykonaj aktualizacje`
## Kolejne migracje
1. Dodaj nowy plik SQL w `database/migrations`.
2. Wrzuc plik na serwer.
3. Uruchom aktualizacje z panelu albo z CLI.

View File

@@ -0,0 +1,44 @@
# Orders Schema Draft (Generic)
## Context
- This is a generic schema proposal for external orders import/sync.
- API docs from Apilo were used only as an example of a rich order payload shape.
- Target is integration-agnostic storage (no vendor lock in table/column names).
## Proposed tables
- `orders`
- `order_addresses`
- `order_items`
- `order_payments`
- `order_shipments`
- `order_documents`
- `order_notes`
- `order_status_history`
- `order_tags_dict`
- `order_tag_links`
- `integration_order_sync_state`
SQL draft:
- [20260302_orders_schema_v1.sql](/C:/visual%20studio%20code/projekty/orderPRO/database/drafts/20260302_orders_schema_v1.sql)
## Design principles
- Keep one row per imported source order in `orders`.
- Store child collections in dedicated tables (1:N).
- Keep source IDs and selected business scalars as first-class columns.
- Keep raw payload snapshots (`payload_json`) for diagnostics and replay safety.
- Keep import idempotent:
- unique `(integration_id, source_order_id)` in `orders`,
- unique child keys per order and source child ID.
- Keep event timeline in separate `order_status_history`.
## Why these extra tables
- `order_status_history`: audit + timeline reconstruction + sync debugging.
- `integration_order_sync_state`: robust incremental fetch cursor.
- `order_tags_*`: proper many-to-many, easy filtering and deduplication.
## Notes before implementation
- Draft is intentionally in `database/drafts` (not auto-run by Migrator).
- Before production migration:
- confirm source-specific mapping in service layer,
- confirm retention policy for `payload_json`,
- decide merge strategy for child rows (upsert by source IDs vs hard refresh per sync).

View File

@@ -1,235 +0,0 @@
# orderPRO - Plan wdrozenia modulu powiazan produktow (shopPRO + marketplace)
Data: 2026-02-23
Status: draft do implementacji
## Status realizacji (2026-02-23)
- Etap A: wykonany
- Etap B: wykonany
- Etap C: wykonany
- Etap D: wykonany (LinkMatcherService, priorytety EAN/SKU, endpoint sugestii)
- Etap E: w toku (UI historii zdarzen + soft-unlink i audyt gotowe; do domkniecia testy manualne E2E)
## Ustalenie implementacyjne (2026-02-23)
- Import produktu z integracji tworzy od razu powiazanie w `product_channel_map` z ustawionym `integration_id` dla tej konkretnej instancji konta.
## 1. Cel etapu
Zbudowac osobny modul "Powiazania ofert" pozwalajacy mapowac produkty orderPRO do ofert zewnetrznych z instancji:
- shopPRO,
- marketplace.
Modul ma dzialac jako osobna karta (analogicznie do podejscia Apilo/BaseLinker), ale osadzona w obecnej architekturze orderPRO.
## 2. Stan obecny i luka
### 2.1 Co juz jest
- tabele `sales_channels` oraz `product_channel_map`,
- seeding kanalow (`shoppro`, `allegro`, `erli`) w `IntegrationRepository::ensureSalesChannelsSeeded()`,
- podstawowe mapowanie `product_id + channel_code + external ids` przez `upsertProductChannelMap(...)`.
### 2.2 Czego brakuje
- brak rozroznienia instancji kont (np. kilka kont marketplace),
- brak lokalnego cache ofert zewnetrznych do wyszukiwania i laczenia,
- brak statusow powiazania i konfliktow,
- brak dedykowanego UI "Powiazania",
- brak audytu operacji recznych (powiaz/przepnij/odlacz).
## 3. Zakres MVP modulu powiazan
W etapie MVP wdrazamy:
1. osobna karta "Powiazania" na szczegolach produktu,
2. reczne powiazanie produktu orderPRO z oferta zewnetrzna,
3. auto-podpowiedzi po `EAN` i `SKU` (bez automatycznego zapisu),
4. odlaczanie i przepinanie powiazan,
5. podstawowy audit trail w osobnej tabeli logow,
6. import/listowanie ofert z aktywnych integracji do lokalnej tabeli cache.
W MVP NIE wdrazamy:
- pelnej, automatycznej synchronizacji cen/stanow,
- fuzzy matching po nazwie jako auto-link,
- masowych operacji na setkach rekordow naraz.
## 4. Model danych (docelowy dla MVP + rozszerzalny)
### 4.1 Rozszerzenie istniejacej tabeli mapowania
Tabela: `product_channel_map` (istniejaca)
Dodac kolumny:
- `integration_id` INT UNSIGNED NULL (FK -> `integrations.id`) - wskazuje konkretna instancje konta,
- `link_type` VARCHAR(32) NOT NULL DEFAULT 'manual' (`manual`, `auto_sku`, `auto_ean`),
- `link_status` VARCHAR(32) NOT NULL DEFAULT 'active' (`active`, `conflict`, `inactive`, `unverified`),
- `confidence` TINYINT UNSIGNED NULL,
- `linked_at` DATETIME NULL,
- `linked_by_user_id` INT UNSIGNED NULL (FK -> `users.id`),
- `unlinked_at` DATETIME NULL,
- `unlinked_by_user_id` INT UNSIGNED NULL (FK -> `users.id`),
- `sync_meta_json` JSON NULL (pole techniczne pod dane z API).
Indeksy:
- `(integration_id, external_product_id, external_variant_id)`,
- `(product_id, link_status)`,
- `(channel_id, link_status)`.
Uwagi:
- utrzymac kompatybilnosc ze starym kodem (`upsertProductChannelMap`),
- `integration_id` moze byc NULL tylko dla rekordow historycznych.
### 4.2 Nowa tabela cache ofert zewnetrznych
Tabela: `channel_offers`
- `id` INT UNSIGNED PK
- `integration_id` INT UNSIGNED NOT NULL FK -> `integrations.id`
- `channel_id` INT UNSIGNED NOT NULL FK -> `sales_channels.id`
- `external_product_id` VARCHAR(128) NOT NULL
- `external_variant_id` VARCHAR(128) NULL
- `external_offer_id` VARCHAR(128) NULL
- `name` VARCHAR(255) NOT NULL
- `sku` VARCHAR(128) NULL
- `ean` VARCHAR(32) NULL
- `price_brutto` DECIMAL(12,2) NULL
- `quantity` DECIMAL(12,3) NULL
- `currency` VARCHAR(8) NULL
- `offer_status` VARCHAR(32) NOT NULL DEFAULT 'active'
- `source_updated_at` DATETIME NULL
- `last_seen_at` DATETIME NOT NULL
- `payload_json` JSON NULL
- `created_at`, `updated_at`
Unikalnosc:
- `UNIQUE (integration_id, external_product_id, external_variant_id)`.
### 4.3 Nowa tabela logow powiazan
Tabela: `product_link_events`
- `id` INT UNSIGNED PK
- `product_channel_map_id` INT UNSIGNED NOT NULL FK -> `product_channel_map.id`
- `event_type` VARCHAR(32) NOT NULL (`linked`, `relinked`, `unlinked`, `status_changed`, `conflict_detected`)
- `before_json` JSON NULL
- `after_json` JSON NULL
- `created_by_user_id` INT UNSIGNED NULL FK -> `users.id`
- `created_at` DATETIME NOT NULL
## 5. Architektura aplikacyjna (orderPRO)
Nowy modul: `src/Modules/ProductLinks`
Klasy:
- `ProductLinksController` - karta "Powiazania", akcje reczne,
- `ProductLinksService` - logika powiaz/przepnij/odlacz + reguly konfliktow,
- `ProductLinksRepository` - odczyt/zapis `product_channel_map` i `product_link_events`,
- `ChannelOffersRepository` - odczyt cache ofert,
- `LinkMatcherService` - podpowiedzi po EAN/SKU,
- `OfferImportService` - import ofert z API integracji do `channel_offers`.
Wspolpraca z istniejacymi modulami:
- `Settings/IntegrationRepository` - lista aktywnych instancji,
- `Products/ProductRepository` - dane produktu lokalnego,
- `Core/Security/Csrf` + `Auth` - autoryzacja i CSRF dla akcji POST.
## 6. UI/UX (osobna karta)
### 6.1 Widok produktu
W `resources/views/products/show.php` dodac zakladke:
- `Powiazania`.
Nowy widok czesciowy:
- `resources/views/products/partials/links.php`.
### 6.2 Sekcje karty
1. Aktualne powiazania:
- instancja (`integrations.name`),
- kanal (`sales_channels.name`),
- oferta (`name`, `external_product_id`, `external_variant_id`),
- dopasowanie (`link_type`, `link_status`, `confidence`),
- akcje: `Przepnij`, `Odlacz`.
2. Wyszukiwarka ofert:
- filtr po instancji,
- filtr po SKU/EAN/nazwie,
- lista wynikow z `channel_offers`,
- akcja `Powiaz`.
3. Podpowiedzi automatyczne:
- sekcja "Proponowane dopasowania" (EAN/SKU),
- tylko sugestia, finalny zapis reczny przez operatora.
### 6.3 Potwierdzenia i alerty
Krytyczne akcje (`Przepnij`, `Odlacz`) realizowac przez:
- `window.OrderProAlerts.confirm(...)`.
Nie dodawac natywnych `alert()` i `confirm()`.
## 7. API / routing wewnetrzny (SSR + POST)
Proponowane endpointy:
- `GET /products/{id}/links` - zwrot zawartosci karty (lub render w `show`),
- `POST /products/{id}/links` - utworzenie recznego powiazania,
- `POST /products/{id}/links/{mapId}/relink` - przepiecie na inna oferte,
- `POST /products/{id}/links/{mapId}/unlink` - odlaczenie,
- `GET /products/{id}/links/suggestions` - sugestie EAN/SKU.
Backoffice import ofert:
- `POST /settings/integrations/{id}/offers/import` - reczny import cache,
- docelowo cron/job: `php bin/cron_import_offers.php` (po MVP).
## 8. Reguly biznesowe
1. Jeden rekord oferty zewnetrznej (`integration_id + external_product_id + external_variant_id`) moze byc aktywnie powiazany tylko z jednym produktem orderPRO.
2. Jeden produkt orderPRO moze miec wiele powiazan (multi-channel, multi-instance).
3. Powiazanie reczne ma priorytet nad sugestiami auto-match.
4. `EAN exact` ma wyzszy priorytet sugestii niz `SKU exact`.
5. `SKU normalized` (bez spacji, myslnikow, podkreslen) jest nizszy niz `SKU exact`.
6. Konflikty nie modyfikuja automatycznie aktywnego linku - nadaja status `conflict` i wymagaja decyzji operatora.
7. Odlaczenie nie kasuje historii: rekord mapy moze przejsc w `link_status = inactive`.
## 9. Etapy wdrozenia
## Etap A - migracje i repozytoria
- migracja rozszerzajaca `product_channel_map`,
- nowe tabele `channel_offers`, `product_link_events`,
- repozytoria i podstawowe testy manualne SQL.
Kryterium akceptacji:
- migracje przechodza lokalnie,
- mozna zapisac i odczytac link wraz z `integration_id` i `link_status`.
## Etap B - import i cache ofert
- adapter importu ofert z aktywnych integracji,
- upsert do `channel_offers`,
- reczny trigger importu z panelu Integracje.
Kryterium akceptacji:
- po imporcie widoczne sa oferty w cache dla wskazanej integracji,
- kolejne importy aktualizuja rekordy bez duplikatow.
## Etap C - UI karty Powiazania
- dodanie zakladki w szczegolach produktu,
- lista aktywnych powiazan,
- formularz recznego powiazania,
- akcje odlacz/przepnij z `OrderProAlerts.confirm`.
Kryterium akceptacji:
- operator bez SQL moze powiazac, przepiac i odlaczyc oferte.
## Etap D - sugestie i konflikty
- `LinkMatcherService` (EAN/SKU),
- widok sugestii,
- logika konfliktu i statusy.
Kryterium akceptacji:
- sugestie sa widoczne i oznaczone confidence,
- konflikt nie psuje istniejacego aktywnego mapowania.
## Etap E - hardening i audyt
- logowanie zdarzen do `product_link_events`,
- dopracowanie komunikatow i walidacji,
- testy manualne end-to-end.
Kryterium akceptacji:
- kazda operacja reczna ma wpis w historii,
- UI poprawnie pokazuje status i ostatnia zmiane.
## 10. Definicja "Done" dla modulu
- istnieje osobna karta "Powiazania" na produkcie,
- mozliwe jest reczne mapowanie produkt <-> oferta (shopPRO/marketplace),
- dziala odlaczanie i przepinanie z potwierdzeniem UI,
- cache ofert z integracji dziala i jest przeszukiwalny,
- konflikty sa oznaczane i nie niszcza danych,
- operacje sa audytowane.
## 11. Kolejny krok po MVP
Po MVP uruchomic:
1. job/cron cyklicznego importu ofert,
2. automatyczne auto-linkowanie tylko dla przypadkow `confidence >= prog`,
3. masowe operacje mapowania (bulk),
4. wykorzystanie powiazan w sync cen/stanow i publikacji ofert.

View File

@@ -1,289 +0,0 @@
# orderPRO - Plan wdrozenia modulu produktow (bez importu/eksportu)
Data: 2026-02-23
Status: draft do implementacji
## 1. Cel etapu
Zbudowac lokalny modul produktow w orderPRO, gotowy pod obsluge wielu kanalow sprzedazy (co najmniej 2 sklepy shopPRO), ale bez uruchamiania synchronizacji import/export.
W tym etapie tworzymy:
- model danych produktu w orderPRO,
- panel do listowania, filtrowania, podgladu i edycji produktow,
- obsluge wariantow i atrybutow,
- podstawy pod przyszle mapowanie kanalowe (shopPRO, Allegro, Erli).
W tym etapie NIE tworzymy:
- importu z shopPRO,
- eksportu do shopPRO,
- eksportu do marketplace,
- schedulerow/cronow synchronizacji produktow.
## 2. Zalozenia domenowe
1. orderPRO jest source of truth dla danych produktowych po wdrozeniu tego etapu.
2. Produkt ma stabilny identyfikator lokalny oraz techniczny UUID do laczenia z integracjami.
3. Jeden produkt moze miec wiele wariantow.
4. Ceny sa przechowywane lokalnie i przeliczane dwukierunkowo (brutto <-> netto) na podstawie VAT.
5. Stan magazynowy jest przechowywany wylacznie na produkcie glownym.
6. Modul ma byc przygotowany pod wiele kanalow, ale bez aktywnych procesow synchronizacji.
7. W MVP obslugiwany jest jezyk `pl`, z zachowaniem struktury pod kolejne jezyki.
## 3. Zakres funkcjonalny MVP modulu produktow
### 3.1 Lista produktow
- paginacja,
- wyszukiwanie po nazwie, SKU, EAN,
- filtry: status, typ (prosty/wariantowy), producent, data modyfikacji,
- sortowanie: id, nazwa, SKU, cena, stan, status, data modyfikacji,
- widoczne flagi: aktywny/nieaktywny, promowany.
### 3.2 Szczegoly produktu
- dane glowne: nazwa, SKU, EAN, status, promoted,
- ceny: brutto/netto + promo,
- stan magazynowy i waga,
- VAT i jednostka,
- kategorie lokalne,
- opis i meta (PL jako minimum),
- galeria zdjec (metadane i kolejnosc).
### 3.3 Tworzenie i edycja produktu
- formularz z walidacja,
- partial update,
- historia zmian (minimum: kto, kiedy, co zmienil).
### 3.4 Warianty
- dodawanie wariantu po kombinacji atrybutow,
- walidacja unikalnosci kombinacji,
- osobne pola SKU/EAN/cena/waga/status dla wariantu (bez osobnego stanu),
- aktywacja/dezaktywacja wariantu.
### 3.5 Atrybuty i wartosci
- slownik atrybutow i wartosci lokalnych,
- oznaczenie typu atrybutu,
- mozliwosc przypisania wielu atrybutow do produktu.
## 4. Model danych (propozycja)
### 4.1 Tabele glówne
1. `products`
- `id` BIGINT PK
- `uuid` CHAR(36) UNIQUE
- `type` ENUM('simple','variant_parent')
- `sku` VARCHAR(128) NULL UNIQUE
- `ean` VARCHAR(32) NULL
- `status` TINYINT(1)
- `promoted` TINYINT(1)
- `vat` DECIMAL(5,2) NULL
- `weight` DECIMAL(10,3) NULL
- `price_brutto` DECIMAL(12,2)
- `price_brutto_promo` DECIMAL(12,2) NULL
- `price_netto` DECIMAL(12,2) NULL
- `price_netto_promo` DECIMAL(12,2) NULL
- `quantity` DECIMAL(12,3) DEFAULT 0
- `producer_id` BIGINT NULL
- `product_unit_id` BIGINT NULL
- `created_at`, `updated_at`, `deleted_at`
2. `product_translations`
- `id` BIGINT PK
- `product_id` BIGINT FK -> products.id
- `lang` VARCHAR(8)
- `name` VARCHAR(255)
- `short_description` TEXT NULL
- `description` LONGTEXT NULL
- `meta_title` VARCHAR(255) NULL
- `meta_description` VARCHAR(255) NULL
- `meta_keywords` VARCHAR(255) NULL
- `seo_link` VARCHAR(255) NULL
- UNIQUE (`product_id`, `lang`)
3. `product_images`
- `id` BIGINT PK
- `product_id` BIGINT FK
- `storage_path` VARCHAR(255)
- `alt` VARCHAR(255) NULL
- `sort_order` INT DEFAULT 0
- `is_main` TINYINT(1) DEFAULT 0
4. `product_categories`
- `product_id` BIGINT FK
- `category_id` BIGINT FK
- PK (`product_id`, `category_id`)
5. `product_variants`
- `id` BIGINT PK
- `product_id` BIGINT FK -> products.id
- `permutation_hash` VARCHAR(191)
- `sku` VARCHAR(128) NULL UNIQUE
- `ean` VARCHAR(32) NULL
- `status` TINYINT(1)
- `price_brutto` DECIMAL(12,2) NULL
- `price_brutto_promo` DECIMAL(12,2) NULL
- `price_netto` DECIMAL(12,2) NULL
- `price_netto_promo` DECIMAL(12,2) NULL
- `weight` DECIMAL(10,3) NULL
- `created_at`, `updated_at`
- UNIQUE (`product_id`, `permutation_hash`)
6. `product_variant_attributes`
- `variant_id` BIGINT FK -> product_variants.id
- `attribute_id` BIGINT FK
- `value_id` BIGINT FK
- PK (`variant_id`, `attribute_id`)
7. `attributes`
- `id` BIGINT PK
- `type` TINYINT
- `status` TINYINT(1)
8. `attribute_translations`
- `attribute_id` BIGINT FK
- `lang` VARCHAR(8)
- `name` VARCHAR(255)
- PK (`attribute_id`, `lang`)
9. `attribute_values`
- `id` BIGINT PK
- `attribute_id` BIGINT FK
- `status` TINYINT(1)
- `is_default` TINYINT(1)
- `impact_on_price` DECIMAL(12,2) NULL
10. `attribute_value_translations`
- `value_id` BIGINT FK
- `lang` VARCHAR(8)
- `name` VARCHAR(255)
- PK (`value_id`, `lang`)
11. `product_change_log`
- `id` BIGINT PK
- `product_id` BIGINT FK
- `user_id` BIGINT FK -> users.id
- `change_type` VARCHAR(64)
- `before_json` JSON NULL
- `after_json` JSON NULL
- `created_at`
### 4.2 Tabele "future-ready" pod integracje (bez aktywnej synchronizacji)
12. `sales_channels`
- `id` BIGINT PK
- `code` VARCHAR(64) UNIQUE (np. shoppro_1, shoppro_2, allegro, erli)
- `name` VARCHAR(128)
- `type` VARCHAR(64) (shoppro/marketplace)
- `status` TINYINT(1)
13. `product_channel_map`
- `id` BIGINT PK
- `product_id` BIGINT FK
- `channel_id` BIGINT FK
- `external_product_id` VARCHAR(128) NULL
- `external_variant_id` VARCHAR(128) NULL
- `sync_state` VARCHAR(32) DEFAULT 'not_linked'
- `last_sync_at` DATETIME NULL
- UNIQUE (`product_id`, `channel_id`, `external_product_id`, `external_variant_id`)
Uwagi:
- na tym etapie `product_channel_map` jest tylko przygotowaniem danych,
- nie tworzymy procesow, ktore aktualizuja `sync_state` automatycznie.
## 5. Architektura aplikacyjna (orderPRO)
Nowy modul: `src/Modules/Products`
Sugerowane klasy:
- `ProductsController` (SSR: lista, szczegoly, create, update),
- `ProductVariantsController`,
- `AttributesController`,
- `ProductRepository`,
- `VariantRepository`,
- `AttributeRepository`,
- `ProductValidator`,
- `ProductService` (transakcje i reguly domenowe),
- DTO/normalizery do mapowania formularz <-> domena.
Widoki:
- `resources/views/products/index.php`
- `resources/views/products/form.php`
- `resources/views/products/show.php`
- `resources/views/products/partials/variants.php`
- `resources/views/products/partials/attributes.php`
Routing (propozycja):
- `GET /products`
- `GET /products/create`
- `POST /products`
- `GET /products/{id}`
- `POST /products/{id}` (na teraz bez PUT, zgodnie z obecnym stylem formularzy)
- `POST /products/{id}/variants`
- `POST /variants/{id}`
- `GET /attributes`
- `POST /attributes`
## 6. Walidacja i reguly biznesowe
1. Wymagane: minimum jedna nazwa tlumaczenia (`pl.name`) i `price_brutto` dla produktu prostego.
2. `sku` jest unikalne na poziomie produktu glownego (`products.sku`).
3. `sku` wariantu jest unikalne na poziomie wariantu (`product_variants.sku`) gdy jest uzupelnione.
4. Ceny brutto/netto sa liczone dwukierunkowo (na podstawie wpisanego pola i stawki VAT).
5. Wariant musi miec niepusta i unikalna kombinacje atrybutow (`permutation_hash`).
6. Stan magazynowy jest przechowywany tylko na produkcie glownym.
7. `ean` opcjonalny, ale jesli podany to walidowany formatowo.
8. Zmiany krytyczne (cena, status, stan, sku, ean) wpisywane do `product_change_log`.
## 7. Etapy wdrozenia
## Etap A - fundament danych
- migracje tabel produktowych i atrybutowych,
- indeksy pod filtry listy,
- seed bazowych danych slownikowych (opcjonalnie).
Kryterium akceptacji:
- migracje przechodza lokalnie i tabela status w `Settings > Aktualizacja bazy` pokazuje brak pending.
## Etap B - backend CRUD produktu
- repozytoria + serwis + walidacja,
- endpointy/form actions dla create/edit/show/list,
- log zmian.
Kryterium akceptacji:
- mozna utworzyc i edytowac produkt prosty,
- walidacje dzialaja,
- zmiany zapisuja sie w `product_change_log`.
## Etap C - warianty i atrybuty
- CRUD atrybutow i wartosci,
- dodawanie/edycja wariantow,
- unikalnosc kombinacji atrybutow,
- obsluga konwersji simple <-> variant_parent.
Kryterium akceptacji:
- mozna dodac wiele wariantow jednego produktu,
- system blokuje duplikat kombinacji,
- lista i szczegoly poprawnie prezentuja warianty.
## Etap D - UI/UX panelu i stabilizacja
- finalne widoki i filtry,
- komunikaty flash i bledy,
- porzadkowanie tlumaczen `resources/lang/pl.php`,
- testy manualne flow end-to-end.
Kryterium akceptacji:
- caly flow produktowy dziala bez SQL manualnego,
- menu zawiera sekcje "Produkty",
- formularze sa zgodne z istniejacym stylem aplikacji.
## 8. Ustalenia biznesowe (2026-02-23)
1. `sku` jest unikalne na poziomie produktu glownego.
2. `sku` jest unikalne rowniez na poziomie wariantu (jesli wariant ma wypelnione SKU).
3. Ceny netto/brutto sa wyliczane automatycznie w obie strony.
4. Stan magazynowy jest tylko na produkcie glownym.
5. MVP dziala na jezyku `pl`, ale struktura danych zostaje wielojezyczna.
6. Kategorie w tym etapie tylko jako relacja (`product_categories`), bez CRUD kategorii.
7. Zdjecia sa przechowywane na serwerze orderPRO.
8. Dozwolona jest konwersja produktu prostego na wariantowy i odwrotnie.
9. Uprawnienia szczegolowe do modulu produktow sa odlozone (dostep dla zalogowanych).
## 9. Definicja "Done" dla tego etapu
- modul produktow dostepny z panelu,
- kompletne CRUD dla produktu prostego,
- obsluga wariantow i atrybutow,
- dzialajace filtrowanie i paginacja,
- log zmian,
- przygotowane mapowanie kanalowe (tabele), ale bez aktywnej synchronizacji.
## 10. Co dalej po tym etapie
Kolejny etap to osobny plan: import produktow z 2x shopPRO + export z orderPRO do shopPRO/Allegro/Erli z kolejka, retry i monitorowaniem sync.

View File

@@ -1,213 +0,0 @@
# orderPRO - Plan projektu (MVP)
Data: 2026-02-19
## 0. Stan aktualny (wdrozone)
- dziala logowanie/wylogowanie z sesja, middleware i CSRF,
- dziala routing HTTP i renderowanie widokow SSR (wlasny lekki rdzen),
- jest bazowy panel (`topbar + content`) oraz ekran logowania,
- teksty interfejsu zostaly przeniesione do tlumaczen (`resources/lang/pl.php`),
- style sa utrzymywane w SCSS (`resources/scss`) i kompilowane/minifikowane do `public/assets/css`,
- wyglad loginu i panelu zostal ujednolicony pod styl adsPRO (kolory, typografia, buttony, spacing).
## 1. Cel
Zbudowac uproszczony panel do obslugi zamowien (inspiracja: Baselinker/Apilo/Sellasist), dzialajacy na hostingu wspoldzielonym (DirectAdmin), bez pelnego frameworka PHP.
Zakres MVP:
- import zamowien z wlasnego sklepu i Allegro,
- obsluga zamowien w panelu (statusy, notatki),
- tworzenie przesylek przez Apaczka (recznie przez przyciski),
- integracja z zewnetrznym systemem faktur/paragonow,
- uproszczona synchronizacja stanow magazynowych.
## 2. Zalozenia techniczne
- PHP: 8.4
- Baza: MySQL 8.x
- Hosting: wspoldzielony (DirectAdmin), cron dostepny
- Skala startowa: do 50 zamowien dziennie
- Model: jedna firma (single-tenant)
- Styl wdrozenia: szybkie MVP
## 3. Architektura (bez pelnego frameworka)
Podejscie: modularny monolit + lekki wlasny rdzen.
Elementy:
- Router HTTP (wlasny)
- Kontrolery + serwisy domenowe
- Warstwa dostepu do danych (repozytoria)
- Widoki SSR (natywne PHP templates)
- Integracje przez adaptery API (Guzzle)
- Kolejka oparta o MySQL (`jobs`) uruchamiana z crona
Zalecane biblioteki Composer:
- obecnie brak dodatkowych bibliotek runtime (rdzen jest autorski),
- biblioteki integracyjne/testowe beda dodawane etapowo, gdy pojawia sie realne moduly integracji.
## 4. Struktura katalogow
```txt
orderPRO/
public/
index.php
assets/
css/
js/
img/
bootstrap/
app.php
container.php
config/
app.php
database.php
queue.php
integrations.php
src/
Core/
Http/
Routing/
Security/
Database/
Queue/
View/
Support/
Modules/
Auth/
Dashboard/
Orders/
Inventory/
Shipping/
Documents/
Integrations/
Store/
Allegro/
Apaczka/
Billing/
database/
migrations/
seeders/
storage/
logs/
cache/
sessions/
tmp/
bin/
cron_sync.php
cron_queue.php
cron_tracking.php
tests/
Unit/
Feature/
DOCS/
PLAN_PROJEKTU.md
BACKLOG_MIKROZADANIA.md
```
## 5. Glowne moduly MVP
1. Auth
- logowanie i wylogowanie
- reset hasla
- ochrona sesji i CSRF
2. Orders
- lista zamowien (filtry, statusy)
- szczegoly zamowienia
- notatki i historia zmian statusu
3. Integrations
- wlasny sklep: import zamowien
- Allegro: OAuth + import zamowien
- Apaczka: utworzenie przesylki i pobranie etykiety
- Billing: tworzenie dokumentow w zewnetrznym systemie
4. Inventory (uproszczone)
- magazyn logiczny (SKU, ilosc)
- rezerwacja przy zamowieniu
- zwolnienie/anulowanie rezerwacji
5. Jobs + Cron
- przetwarzanie zadan asynchronicznych
- retry i log bledow
## 6. Model danych (MVP)
Tabele:
- `users`
- `channels` (konfiguracje API: store/allegro/apaczka/billing)
- `orders`
- `order_items`
- `order_addresses`
- `order_status_history`
- `shipments`
- `shipment_labels`
- `documents` (ID/link w systemie zewnetrznym)
- `inventory_items`
- `inventory_reservations`
- `jobs`
- `failed_jobs`
- `sync_runs`
- `sync_errors`
- `webhook_events` (na przyszlosc)
## 7. Synchronizacja i cron
Konfiguracja na shared hostingu:
- co 5 min: `php bin/cron_sync.php` (import zamowien)
- co 1-2 min: `php bin/cron_queue.php` (obsluga jobow)
- co 15 min: `php bin/cron_tracking.php` (statusy przesylek)
Zasady:
- krotkie zadania (batch np. po 20-50 rekordow),
- timeouty i retry z backoff,
- logowanie kazdej proby integracji.
## 8. Integracje (kolejnosc)
1. Wlasny sklep (pierwszy connector)
2. Allegro (drugi connector)
3. Apaczka (tworzenie przesylki z przycisku)
4. System billingowy (faktura/paragon przez API)
## 9. Plan etapow
## Etap 0 - Fundament
- skeleton aplikacji
- routing, DI/container, konfiguracja `.env`
- baza i migracje
- logowanie uzytkownika
- layout panelu admin
## Etap 1 - Orders + wlasny sklep
- import zamowien
- lista i szczegoly
- reczna zmiana statusow
## Etap 2 - Allegro
- autoryzacja OAuth
- pobieranie i mapowanie zamowien
## Etap 3 - Apaczka
- tworzenie przesylki
- etykieta PDF
- zapis numeru tracking
## Etap 4 - Billing + stock
- wystawienie dokumentu przez API
- uproszczony stock sync
## Etap 5 - Stabilizacja
- retry, logi, obsluga bledow
- testy kluczowych flow
- backup i checklista produkcyjna
## 10. Wymagania niefunkcjonalne
- bezpieczenstwo:
- hashowanie hasel (`password_hash`)
- CSRF tokeny
- walidacja wejscia
- escaping danych w widokach
- audyt:
- logi dzialan uzytkownika i integracji
- wydajnosc:
- paginacja list
- indeksy DB pod filtry zamowien i statusow
## 11. Decyzje odlozone na pozniej
- multi-tenant (wiele firm)
- zaawansowana dokumentacja magazynowa (WZ/PZ)
- automatyka regul biznesowych
- migracja na VPS / Docker

131
DOCS/TECH_CHANGELOG.md Normal file
View File

@@ -0,0 +1,131 @@
# Tech Changelog
## 2026-03-02
- Dodano zakladke `Ustawienia > Statusy` do zarzadzania:
- grupami statusow (z kolorem na poziomie grupy),
- statusami przypisanymi do grup.
- Dodano migracje `20260302_000022_create_order_status_groups_and_statuses_tables.sql`:
- tabela `order_status_groups`,
- tabela `order_statuses` z FK `order_statuses_group_fk` i kasowaniem kaskadowym.
- Dodano `App\Modules\Settings\OrderStatusRepository` (CRUD grup/statusow i walidacja unikalnosci kodow).
- Rozszerzono `App\Modules\Settings\SettingsController` o endpointy:
- `statuses`,
- `createStatusGroup`, `updateStatusGroup`, `deleteStatusGroup`,
- `createStatus`, `updateStatus`, `deleteStatus`.
- Rozszerzono routing o trasy `/settings/statuses*` i `/settings/status-groups*`.
- Sidebar ustawien ma nowy link `Statusy`.
- Dodano widok `resources/views/settings/statuses.php` oraz style SCSS dla formularzy/akcji tego widoku.
- Potwierdzenia usuwania w nowym widoku realizowane sa przez `window.OrderProAlerts.confirm(...)`.
- Przebudowano UI `Ustawienia > Statusy`:
- 2 taby (`Statusy`, `Grupy statusow`),
- sortowanie realizowane przez drag-and-drop z automatycznym zapisem kolejnosci po upuszczeniu.
- Skondensowano UI zakladki `Ustawienia > Statusy`:
- elementy listy statusow i grup maja bardziej kompaktowy, jednoliniowy uklad,
- zmniejszono paddingi/gapy i wysokosci kontrolek, aby zwiekszyc ilosc danych widocznych bez scrolla.
- Wprowadzono globalna preferencje kompaktowego UI w `AGENTS.md`.
- Poprawiono generowanie `code` dla statusow/grup: polskie znaki sa transliterowane do ASCII
(np. `Nieopłacone` -> `nieoplacone`), zamiast zamiany na `_`.
- Dodano skrypt serwisowy `bin/fix_status_codes.php`:
- przelicza kody grup/statusow na podstawie aktualnych nazw z transliteracja PL->ASCII,
- zapewnia unikalnosc kodow (`_2`, `_3` przy konfliktach),
- wspiera `--dry-run` i `--use-remote`.
- Wykonano naprawe kodow na bazie zdalnej (`--use-remote`): zaktualizowano 2 grupy i 1 status.
- Przygotowano draft generycznego schematu tabel zamowien (Apilo tylko jako przyklad pol API):
- dokumentacja: `DOCS/ORDERS_SCHEMA_DRAFT.md`,
- draft SQL (nieuruchamiany automatycznie): `database/drafts/20260302_orders_schema_v1.sql`.
- Wdrozono generyczny schema zamowien na bazie docelowej przez `bin/deploy_and_seed_orders.php`.
- Zasiano dane testowe:
- `orders`: 30,
- `order_items`: 90,
- `order_status_history`: 123,
- pozostale kolekcje (adresy/platnosci/wysylki/dokumenty/notatki/tagi) proporcjonalnie.
- Dodano endpointy zapisu kolejnosci:
- `POST /settings/status-groups/reorder`,
- `POST /settings/statuses/reorder`.
- Zmieniono obsluge pola `code`:
- `code` jest automatycznie generowany przy tworzeniu z nazwy,
- po utworzeniu jest tylko do odczytu i nie podlega edycji z formularza.
- Reset projektu do trybu `users-only`.
- Zarchiwizowano moduly poza `Auth` i `Users` do `archive/2026-03-02_users-only-reset/`.
- Uproszczono routing i layout do obslugi logowania i zarzadzania uzytkownikami.
- Ustalono nowy standard dokumentacji technicznej w plikach root:
- `DB_SCHEMA.md`
- `ARCHITECTURE.md`
- `TECH_CHANGELOG.md`
- Przywrocono sekcje `Ustawienia` w nawigacji jako grupe z podkategoriami:
- `Uzytkownicy` (`/users`)
- `Baza danych` (`/settings/database`)
- Dodano modul `App\Modules\Settings` z kontrolerem `SettingsController` (metody `database`, `migrate`).
- Przywrocono reczne uruchamianie migracji z UI:
- `GET /settings/database` (status migracji + lista pending plikow),
- `POST /settings/database/migrate` (wykonanie pending migracji + log ostatniego uruchomienia).
- Zmieniono tlumaczenie `settings.database.title` na `Baza danych` oraz dodano `navigation.database`.
- Poprawiono redirect po logowaniu (`AuthController`): `/dashboard` -> `/settings/users`.
- Usunieto wewnetrzny pasek zakladek (`settings-nav`) z podstron ustawien.
- Podstrona uzytkownikow jest adresowana jako `GET/POST /settings/users` (z zachowaniem tras kompatybilnosci `/users`).
- Usunieto z podstron ustawien blok naglowkowy `Ustawienia` + opis, aby zwiekszyc obszar roboczy.
- Rozszerzono `bin/deploy_and_seed_orders.php` o parametr `--profile=default|realistic`.
- Dodano realistyczny profil seedowania:
- wazone losowanie statusow i metod platnosci,
- spojne mapowanie `external_status_id` -> `payment_status` i `total_paid`,
- bardziej realne reguly tworzenia wpisow `order_payments`, `order_shipments`, `order_documents`,
- historia statusow oparta na logicznych sciezkach przejsc (zamiast losowych skokow).
- Wykonano ponowne wdrozenie draftu i seed z profilem realistycznym:
- komenda: `C:\xampp\php\php.exe bin/deploy_and_seed_orders.php --use-remote --count=30 --profile=realistic`,
- wynik: `orders=30`, `order_items=94`, `order_status_history=81`.
- Dodano glowna sekcje panelu `Zamowienia` z podzakladka `Lista zamowien`.
- Wdrozone endpointy:
- `GET /orders` (redirect do `/orders/list`),
- `GET /orders/list` (widok listy).
- Dodano modul aplikacyjny:
- `App\Modules\Orders\OrdersController`,
- `App\Modules\Orders\OrdersRepository`.
- Widok listy zamowien opiera sie o aktualna baze (`orders`, `order_addresses`, `order_items`, `order_shipments`, `order_documents`) i udostepnia:
- filtry (fraza, zrodlo, status, status platnosci, zakres dat),
- sortowanie i paginacje,
- kompaktowe komorki (referencje, klient, status+platnosc, pozycje, kwoty, wysylka, daty),
- skrócone statystyki (`wszystkie`, `oplacone`, `wyslane`).
- Rozszerzono liste zamowien o podglad produktow w zamowieniu:
- nazwa produktu,
- miniatura (z `order_items.media_url`, fallback bez obrazu),
- ilosc sztuk per pozycja,
- licznik dodatkowych pozycji poza limitem podgladu.
- Miniatury produktow na liscie zamowien zostaly powiekszone o 100% (uklad bardziej czytelny).
- Dodano modal podgladu zdjecia po kliknieciu miniatury produktu na liscie zamowien.
- Status w kolumnie statusow jest prezentowany jako nazwa biznesowa (np. `Nowe`, `W realizacji`) bez technicznego kodu.
- Dodano skrypt serwisowy `bin/fill_order_item_images.php` do uzupelniania pustych `order_items.media_url`
losowymi URL (`picsum.photos`) i wykonano go na bazie zdalnej (`--use-remote`, zaktualizowano 94 rekordy).
- Rozszerzono sidebar o grupe `Zamowienia` z podlinkiem `Lista zamowien`.
- Dodano widok szczegolow zamowienia:
- endpoint `GET /orders/{id}`,
- link do szczegolow po kliknieciu numeru zamowienia na liscie,
- uklad sekcji inspirowany widokiem Apilo: pozycje, dane zamowienia, platnosc/wysylka, adresy, notatki, historia.
- Dopracowano widok `GET /orders/{id}` do ukladu bardziej zblizonego do Apilo:
- lewy panel statusow z licznikami,
- prawa kolumna szczegolow z paskiem akcji i tabami sekcji,
- aktywne wyroznienie biezacego statusu zamowienia.
- Dodano taki sam lewy panel statusow na `GET /orders/list`:
- grupy statusow z licznikami,
- klikniecie statusu filtruje liste zamowien po `status`,
- kolorowe liczniki per status (info/warn/success/danger).
- Poprawiono zrodlo panelu statusow (lista + szczegoly):
- podzial na grupy i nazwy statusow sa pobierane dynamicznie z `order_status_groups` + `order_statuses`,
- kolory pochodza z `order_status_groups.color_hex`,
- dla statusow nieprzypisanych do konfiguracji dodawana jest sekcja `Pozostale`.
- Ujednolicono render panelu statusow jako jeden widget widoku:
- nowy komponent `resources/views/components/order-status-panel.php`,
- komponent jest wspolnie uzywany przez `orders/list.php` i `orders/show.php`,
- statusy w szczegolach zamowienia sa klikalne (przejscie do listy z odpowiednim filtrem).
- Dodano klikalne taby w `orders/show.php`:
- przelaczanie sekcji bez przeladowania strony (JS),
- aktywny panel `Szczegoly zamowienia`,
- pozostale panele (`Historia zmian`, `Przesylki`, `Platnosci`, `Dokumenty`) zawieraja tymczasowe puste boksy.
- Zmieniono seed zamowien (`bin/deploy_and_seed_orders.php`):
- `external_status_id` jest losowany z aktywnych statusow z tabeli `order_statuses` (zgodnie z konfiguracja w `Ustawienia > Statusy`),
- dodano fallback do listy domyslnej, jesli tabela jest pusta/niedostepna,
- profil `realistic` ma fallback reguly finansowej dla niestandardowych statusow.
- Dodano skrypt serwisowy `bin/randomize_order_statuses.php`:
- losowo podmienia `orders.external_status_id` dla juz istniejacych zamowien na aktywne statusy z `order_statuses`,
- aktualizuje tez `is_canceled_by_buyer` dla statusu `cancelled`,
- wspiera `--use-remote` i `--dry-run`.
- Wykonano podmiane statusow na bazie zdalnej (`--use-remote`): zaktualizowano 30 zamowien.

View File

@@ -1,6 +0,0 @@
5. Rozbudować dane o producencie o pola z shopPRO
11. Nowa zakładka ze stanami magazynowyi z inputami do szybkiego wpisania aktualnego stanu magazynowego
13. Możliwość edycji pojedynczych wartości dla integracji shopPRO
14. Możliwość wysyłania wybranych zdjęć przy eksporcie pojedynczego produktu.
16. Obsługa pola Pozwól zamawiać gdy stan 0:
17. Integracja z https://kie.ai/

View File

@@ -1,92 +0,0 @@
# Design: Przypisywanie kategorii shopPRO z poziomu Marketplace orderPRO
**Data:** 2026-02-27
**Status:** Zatwierdzony
## Cel
Umożliwienie przypisywania produktów do kategorii instancji shopPRO bezpośrednio z widoku "Powiązane oferty" w orderPRO, bez konieczności logowania się do panelu shopPRO.
## Architektura
```
Przeglądarka → orderPRO (proxy AJAX) → shopPRO API
```
orderPRO działa jako bezpieczny proxy — klucz API shopPRO nigdy nie trafia do przeglądarki.
## Zakres zmian
### shopPRO (2 pliki)
**1. `autoload/api/Controllers/CategoriesApiController.php`** (nowy)
- Akcja `list` (GET) — zwraca płaską listę **aktywnych** kategorii:
```json
{"status": "ok", "data": {"categories": [{"id": 1, "parent_id": null, "title": "Nazwa"}]}}
```
- Tytuł z `pp_shop_categories_langs` w domyślnym języku sklepu (`pp_langs` WHERE `start=1`)
- Tylko `status=1`
**2. `autoload/api/ApiRouter.php`**
- Rejestracja endpointu `'categories'` → `CategoriesApiController`
### orderPRO (4 pliki)
**1. `src/Modules/Settings/ShopProClient.php`**
- Nowa metoda `fetchCategories(baseUrl, apiKey, timeoutSeconds)`:
```
GET api.php?endpoint=categories&action=list
Zwraca: array{ok:bool, categories:array, message:string}
```
**2. `src/Modules/Marketplace/MarketplaceController.php`**
- `categoriesJson(Request)` → `GET /marketplace/{id}/categories`
- Sprawdza czy integracja jest aktywna i typu `shoppro`
- Wywołuje `ShopProClient::fetchCategories()`
- Zwraca JSON z listą kategorii
- `saveProductCategoriesJson(Request)` → `POST /marketplace/{id}/product/{pid}/categories`
- Waliduje CSRF
- Pobiera `category_ids[]` z body
- Wywołuje `ShopProClient::updateProduct()` z `{"categories": [...]}`
- Zwraca JSON sukces/błąd
**3. `routes/web.php`**
```
GET /marketplace/{integration_id}/categories → categoriesJson
POST /marketplace/{integration_id}/product/{pid}/categories → saveProductCategoriesJson
```
**4. `resources/views/marketplace/offers.php`**
- Nowa kolumna "Kategorie" (tylko gdy `integration.type === 'shoppro'`)
- Przycisk "Przypisz kategorie" z `data-product-id="{external_product_id}"`
- Modal z drzewkiem kategorii (checkbox tree, vanilla JS)
- Aktualne kategorie produktu pobierane z istniejącego `fetchProductById()` przez nowy endpoint
## Przepływ danych (kliknięcie przycisku)
1. Klik "Przypisz kategorie" → spinner w przycisku
2. Równoległe AJAX GET:
- `/marketplace/{id}/categories` → lista kategorii instancji
- `/marketplace/{id}/product/{pid}/categories` → aktualne kategorie produktu
(endpoint wewnętrznie woła `products/get` na shopPRO i zwraca `categories` array)
3. JS buduje drzewo kategorii z płaskiej listy (rekurencyjnie po `parent_id`)
4. Pre-zaznacza checkboxy dla już przypisanych kategorii
5. Modal otwarty
6. Użytkownik zaznacza/odznacza, klika "Zapisz"
7. POST `/marketplace/{id}/product/{pid}/categories` z `{category_ids: [1,5], csrf_token: "..."}`
8. Toast sukcesu lub błędu
## Bezpieczeństwo
- Klucz API shopPRO nigdy nie opuszcza serwera orderPRO
- CSRF token wymagany przy POST
- Walidacja `integration_id` i `external_product_id` (muszą być int > 0)
- Integracja musi być aktywna i należeć do zalogowanego użytkownika (istniejąca AuthService)
## Decyzje projektowe
- Płaska lista kategorii (nie zagnieżdżona) — drzewo buduje JS po stronie klienta
- Vanilla JS — brak dodatkowych zależności
- Tylko aktywne kategorie (status=1)
- Tytuł w domyślnym języku sklepu
- Kategorie cacheowane w pamięci JS na czas sesji strony (jeden fetch per integration)

View File

@@ -1,907 +0,0 @@
# Marketplace Category Assignment Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Dodaj kolumnę "Przypisz kategorie" w widoku powiązanych ofert marketplace orderPRO, która otwiera modal z drzewkiem kategorii shopPRO i umożliwia zapis wybranych kategorii do instancji shopPRO.
**Architecture:** orderPRO działa jako proxy AJAX — przeglądarka nigdy nie widzi klucza API shopPRO. shopPRO dostaje nowy endpoint `categories/list` zwracający płaską listę aktywnych kategorii. Drzewo kategorii buduje vanilla JS po stronie klienta. Zapis kategorii używa istniejącego `products/update` w shopPRO API.
**Tech Stack:** PHP 8.x, vanilla JS (bez dodatkowych bibliotek), `window.OrderProAlerts` (globalny, już załadowany w layoucie), `Response::json()` dla AJAX.
---
## Kontekst — kluczowe pliki
| Plik | Rola |
|------|------|
| `C:\visual studio code\projekty\shopPRO\autoload\api\ApiRouter.php` | Rejestracja endpointów shopPRO API |
| `C:\visual studio code\projekty\shopPRO\autoload\api\Controllers\ProductsApiController.php` | Wzorzec dla nowego kontrolera |
| `C:\visual studio code\projekty\shopPRO\autoload\Domain\Category\CategoryRepository.php` | Metody DB kategorii |
| `C:\visual studio code\projekty\orderPRO\src\Modules\Settings\ShopProClient.php` | Klient HTTP do shopPRO |
| `C:\visual studio code\projekty\orderPRO\src\Modules\Marketplace\MarketplaceController.php` | Kontroler do rozszerzenia |
| `C:\visual studio code\projekty\orderPRO\routes\web.php` | Trasy — dodać 2 nowe |
| `C:\visual studio code\projekty\orderPRO\resources\views\marketplace\offers.php` | Widok tabeli ofert |
| `C:\visual studio code\projekty\orderPRO\resources\lang\pl.php` | Tłumaczenia PL |
---
## Task 1: Nowy endpoint `categories/list` w shopPRO
**Files:**
- Create: `C:\visual studio code\projekty\shopPRO\autoload\api\Controllers\CategoriesApiController.php`
- Modify: `C:\visual studio code\projekty\shopPRO\autoload\api\ApiRouter.php`
### Step 1: Utwórz `CategoriesApiController.php`
```php
<?php
namespace api\Controllers;
use api\ApiRouter;
use Domain\Category\CategoryRepository;
class CategoriesApiController
{
private $categoryRepo;
public function __construct(CategoryRepository $categoryRepo)
{
$this->categoryRepo = $categoryRepo;
}
public function list(): void
{
if (!ApiRouter::requireMethod('GET')) {
return;
}
$db = $GLOBALS['mdb'] ?? null;
if (!$db) {
ApiRouter::sendError('INTERNAL_ERROR', 'Database not available', 500);
return;
}
// Pobierz domyślny język sklepu
$defaultLang = $db->get('pp_langs', 'id', ['start' => 1]);
if (!$defaultLang) {
$defaultLang = 'pl';
}
$defaultLang = (string)$defaultLang;
// Pobierz wszystkie aktywne kategorie (płaska lista)
$rows = $db->select(
'pp_shop_categories',
['id', 'parent_id'],
[
'status' => 1,
'ORDER' => ['o' => 'ASC'],
]
);
if (!is_array($rows)) {
ApiRouter::sendSuccess(['categories' => []]);
return;
}
$categories = [];
foreach ($rows as $row) {
$categoryId = (int)($row['id'] ?? 0);
if ($categoryId <= 0) {
continue;
}
$title = $db->get('pp_shop_categories_langs', 'title', [
'AND' => [
'category_id' => $categoryId,
'lang_id' => $defaultLang,
],
]);
// Fallback: jeśli brak tłumaczenia w domyślnym języku, weź pierwsze dostępne
if (!$title) {
$title = $db->get('pp_shop_categories_langs', 'title', [
'category_id' => $categoryId,
'title[!]' => '',
'LIMIT' => 1,
]);
}
$parentId = $row['parent_id'] !== null ? (int)$row['parent_id'] : null;
$categories[] = [
'id' => $categoryId,
'parent_id' => $parentId,
'title' => (string)($title ?? 'Kategoria #' . $categoryId),
];
}
ApiRouter::sendSuccess(['categories' => $categories]);
}
}
```
### Step 2: Zarejestruj endpoint w `ApiRouter.php`
W metodzie `getControllerFactories()` dodaj wpis `'categories'` **po** wpisie `'dictionaries'`:
```php
'categories' => function () use ($db) {
$categoryRepo = new \Domain\Category\CategoryRepository($db);
return new Controllers\CategoriesApiController($categoryRepo);
},
```
### Step 3: Przetestuj endpoint ręcznie
Wywołaj z terminala (zastąp URL, klucz i ID instancji shopPRO):
```bash
curl -s -H "X-Api-Key: TWOJ_KLUCZ" \
"https://INSTANCJA_SHOPPRO/api.php?endpoint=categories&action=list"
```
Oczekiwany wynik:
```json
{
"status": "ok",
"data": {
"categories": [
{"id": 1, "parent_id": null, "title": "Główna kategoria"},
{"id": 3, "parent_id": 1, "title": "Podkategoria"}
]
}
}
```
### Step 4: Commit w shopPRO
```bash
cd "C:\visual studio code\projekty\shopPRO"
git add autoload/api/Controllers/CategoriesApiController.php autoload/api/ApiRouter.php
git commit -m "feat: add categories/list API endpoint"
```
---
## Task 2: Metoda `fetchCategories()` w `ShopProClient`
**Files:**
- Modify: `C:\visual studio code\projekty\orderPRO\src\Modules\Settings\ShopProClient.php`
### Step 1: Dodaj metodę po `ensureProducer()` (przed `testConnection()`)
```php
/**
* @return array{ok:bool,http_code:int|null,message:string,categories:array<int,array<string,mixed>>}
*/
public function fetchCategories(
string $baseUrl,
string $apiKey,
int $timeoutSeconds
): array {
$normalizedBaseUrl = rtrim(trim($baseUrl), '/');
$endpointUrl = $normalizedBaseUrl . '/api.php?endpoint=categories&action=list';
$response = $this->requestJson($endpointUrl, $apiKey, $timeoutSeconds);
if (($response['ok'] ?? false) !== true) {
return [
'ok' => false,
'http_code' => $response['http_code'] ?? null,
'message' => (string) ($response['message'] ?? 'Nie mozna pobrac kategorii z shopPRO.'),
'categories' => [],
];
}
$data = is_array($response['data'] ?? null) ? $response['data'] : [];
$categories = isset($data['categories']) && is_array($data['categories'])
? $data['categories']
: [];
return [
'ok' => true,
'http_code' => $response['http_code'] ?? null,
'message' => '',
'categories' => $categories,
];
}
```
### Step 2: Commit
```bash
cd "C:\visual studio code\projekty\orderPRO"
git add src/Modules/Settings/ShopProClient.php
git commit -m "feat: add ShopProClient::fetchCategories() method"
```
---
## Task 3: Dwa nowe endpointy AJAX w `MarketplaceController`
**Files:**
- Modify: `C:\visual studio code\projekty\orderPRO\src\Modules\Marketplace\MarketplaceController.php`
### Kontekst — co robi kontroler
Kontroler dostaje z konstruktora: `$template`, `$translator`, `$auth`, `$marketplace` (MarketplaceRepository).
Nie ma `$shopProClient` ani `$integrationRepository` — musimy je dodać przez `IntegrationRepository`.
Spójrz jak `routes/web.php` tworzy kontroler (linia 89-94) — musimy tam dodać `ShopProClient` i `IntegrationRepository`.
### Step 1: Rozszerz konstruktor kontrolera
Zmień sygnaturę konstruktora na:
```php
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly MarketplaceRepository $marketplace,
private readonly \App\Modules\Settings\IntegrationRepository $integrationRepository,
private readonly \App\Modules\Settings\ShopProClient $shopProClient
) {
}
```
### Step 2: Dodaj metodę `categoriesJson()`
Uwaga: używamy `findApiCredentials()` (nie `findById()`) — tylko ta metoda zwraca odszyfrowany `api_key`.
```php
public function categoriesJson(Request $request): Response
{
$integrationId = max(0, (int) $request->input('integration_id', 0));
if ($integrationId <= 0) {
return Response::json(['ok' => false, 'message' => 'Brak integration_id.'], 400);
}
$integration = $this->marketplace->findActiveIntegrationById($integrationId);
if ($integration === null) {
return Response::json(['ok' => false, 'message' => 'Integracja nie istnieje lub jest nieaktywna.'], 404);
}
$creds = $this->integrationRepository->findApiCredentials($integrationId);
if ($creds === null) {
return Response::json(['ok' => false, 'message' => 'Brak danych uwierzytelniających.'], 404);
}
$result = $this->shopProClient->fetchCategories(
(string) ($creds['base_url'] ?? ''),
(string) ($creds['api_key'] ?? ''),
(int) ($creds['timeout_seconds'] ?? 10)
);
if (!($result['ok'] ?? false)) {
return Response::json(['ok' => false, 'message' => $result['message']], 502);
}
return Response::json(['ok' => true, 'categories' => $result['categories']]);
}
```
### Step 3: Dodaj metodę `saveProductCategoriesJson()`
Uwaga: Request::capture() buduje z `$_POST` — dla JSON body potrzebujemy `file_get_contents('php://input')`. Parsujemy ręcznie.
```php
public function saveProductCategoriesJson(Request $request): Response
{
$integrationId = max(0, (int) $request->input('integration_id', 0));
$externalProductId = max(0, (int) $request->input('external_product_id', 0));
if ($integrationId <= 0 || $externalProductId <= 0) {
return Response::json(['ok' => false, 'message' => 'Brak wymaganych parametrów.'], 400);
}
// CSRF z JSON body
$rawBody = (string) file_get_contents('php://input');
$body = json_decode($rawBody, true);
if (!is_array($body)) {
return Response::json(['ok' => false, 'message' => 'Nieprawidłowe ciało żądania JSON.'], 400);
}
$csrfToken = (string) ($body['_token'] ?? '');
if (!\App\Core\Security\Csrf::validate($csrfToken)) {
return Response::json(['ok' => false, 'message' => 'Nieprawidłowy token CSRF.'], 403);
}
$integration = $this->marketplace->findActiveIntegrationById($integrationId);
if ($integration === null) {
return Response::json(['ok' => false, 'message' => 'Integracja nie istnieje lub jest nieaktywna.'], 404);
}
$creds = $this->integrationRepository->findApiCredentials($integrationId);
if ($creds === null) {
return Response::json(['ok' => false, 'message' => 'Brak danych uwierzytelniających.'], 404);
}
$categoryIds = isset($body['category_ids']) && is_array($body['category_ids'])
? array_values(array_filter(array_map('intval', $body['category_ids']), static fn(int $id): bool => $id > 0))
: [];
$result = $this->shopProClient->updateProduct(
(string) ($creds['base_url'] ?? ''),
(string) ($creds['api_key'] ?? ''),
(int) ($creds['timeout_seconds'] ?? 10),
$externalProductId,
['categories' => $categoryIds]
);
if (!($result['ok'] ?? false)) {
return Response::json(['ok' => false, 'message' => $result['message']], 502);
}
return Response::json(['ok' => true]);
}
```
### Step 4: Metoda `IntegrationRepository::findApiCredentials()` — potwierdzone
Użyj `findApiCredentials(int $id): ?array` — zwraca `['id', 'name', 'base_url', 'timeout_seconds', 'api_key']` z odszyfrowanym kluczem. **Nie używaj `findById()`** — ta metoda nie zwraca klucza API.
---
## Task 4: Zaktualizuj `routes/web.php` — konstruktor + 2 nowe trasy
**Files:**
- Modify: `C:\visual studio code\projekty\orderPRO\routes\web.php`
### Step 1: Dodaj `IntegrationRepository` i `ShopProClient` do konstruktora kontrolera
Znajdź blok tworzenia `$marketplaceController` (linia ~89):
```php
$marketplaceController = new MarketplaceController(
$template,
$translator,
$auth,
$marketplaceRepository
);
```
Zastąp:
```php
$marketplaceController = new MarketplaceController(
$template,
$translator,
$auth,
$marketplaceRepository,
$integrationRepository,
$shopProClient
);
```
### Step 2: Dodaj 2 nowe trasy po linii z `/marketplace/{integration_id}`
```php
$router->get('/marketplace/{integration_id}/categories', [$marketplaceController, 'categoriesJson'], [$authMiddleware]);
$router->post('/marketplace/{integration_id}/product/{external_product_id}/categories', [$marketplaceController, 'saveProductCategoriesJson'], [$authMiddleware]);
```
### Step 3: Sprawdź czy router obsługuje parametry w środku ścieżki
Jeśli router nie obsługuje `/marketplace/{id}/product/{pid}/categories` (dwa parametry), użyj query stringa dla `external_product_id`:
```
POST /marketplace/{integration_id}/product-categories?external_product_id={pid}
```
I odpowiednio zaktualizuj metodę kontrolera i JS. Sprawdź jak router obsługuje routing w `src/Core/Router.php`.
### Step 4: Commit
```bash
git add routes/web.php src/Modules/Marketplace/MarketplaceController.php
git commit -m "feat: add AJAX category endpoints to MarketplaceController"
```
---
## Task 5: Sprawdź `IntegrationRepository::findById()`
**Files:**
- Read: `C:\visual studio code\projekty\orderPRO\src\Modules\Settings\IntegrationRepository.php`
Otwórz plik i potwierdź:
1. Nazwa metody zwracającej pełne dane integracji po ID (z odszyfrowanym `api_key`, `base_url`, `timeout_seconds`)
2. Zaktualizuj wywołania w `MarketplaceController` jeśli nazwa jest inna niż `findById()`
Typowe warianty nazwy: `findById()`, `findByIdDecrypted()`, `getById()`, `findWithCredentials()`.
---
## Task 6: Zaktualizuj widok `offers.php` — kolumna + modal + JS
**Files:**
- Modify: `C:\visual studio code\projekty\orderPRO\resources\views\marketplace\offers.php`
### Step 1: Dodaj nagłówek kolumny w `<thead>`
Po ostatnim `<th>` (Ostatnia zmiana), dodaj:
```php
<th>Kategorie</th>
```
### Step 2: Dodaj komórkę z przyciskiem w każdym wierszu `<tbody>`
Po ostatniej komórce `<td>` (`updated_at`), dodaj:
```php
<td>
<button
type="button"
class="btn btn--secondary btn--sm js-assign-categories"
data-integration-id="<?= $e((string) $integrationId) ?>"
data-product-id="<?= $e((string) ($row['external_product_id'] ?? '')) ?>"
>Przypisz kategorie</button>
</td>
```
Gdzie `$integrationId` to zmienna dostępna z danych integracji. Pobierz ją z `$integrationData['id']` na początku widoku:
```php
<?php $integrationId = (int) ($integrationData['id'] ?? 0); ?>
```
### Step 3: Dodaj modal HTML na końcu widoku (przed zamknięciem `</section>`)
```php
<!-- Modal kategorii -->
<div id="categories-modal-backdrop" class="jq-alert-modal-backdrop" style="display:none" aria-hidden="true">
<div class="jq-alert-modal" role="dialog" aria-modal="true" aria-labelledby="categories-modal-title" style="max-width:520px;width:100%">
<div class="jq-alert-modal__header">
<h3 id="categories-modal-title">Przypisz kategorie</h3>
</div>
<div class="jq-alert-modal__body" style="max-height:420px;overflow-y:auto">
<div id="categories-modal-loading" style="padding:1rem;text-align:center">Ładowanie kategorii...</div>
<div id="categories-modal-error" style="display:none" class="alert alert--danger"></div>
<div id="categories-modal-tree" style="display:none"></div>
</div>
<div class="jq-alert-modal__footer">
<button type="button" class="btn btn--secondary" id="categories-modal-cancel">Anuluj</button>
<button type="button" class="btn btn--primary" id="categories-modal-save" style="display:none">Zapisz</button>
</div>
</div>
</div>
```
### Step 4: Dodaj `<script>` na końcu widoku
```php
<script>
(function () {
'use strict';
var csrfToken = <?= json_encode($csrfToken ?? '') ?>;
var backdrop = document.getElementById('categories-modal-backdrop');
var treeEl = document.getElementById('categories-modal-tree');
var loadingEl = document.getElementById('categories-modal-loading');
var errorEl = document.getElementById('categories-modal-error');
var saveBtn = document.getElementById('categories-modal-save');
var cancelBtn = document.getElementById('categories-modal-cancel');
// Stan aktualnie otwartego modalu
var state = {
integrationId: 0,
productId: 0,
allCategories: null, // cache per integration
cachedIntegrationId: 0,
};
// ===== Otwieranie modalu =====
document.addEventListener('click', function (e) {
var btn = e.target.closest('.js-assign-categories');
if (!btn) return;
state.integrationId = parseInt(btn.dataset.integrationId, 10) || 0;
state.productId = parseInt(btn.dataset.productId, 10) || 0;
if (state.integrationId <= 0 || state.productId <= 0) return;
openModal();
loadData();
});
function openModal() {
backdrop.style.display = '';
backdrop.setAttribute('aria-hidden', 'false');
backdrop.classList.add('is-visible');
loadingEl.style.display = '';
treeEl.style.display = 'none';
errorEl.style.display = 'none';
saveBtn.style.display = 'none';
treeEl.innerHTML = '';
}
function closeModal() {
backdrop.classList.remove('is-visible');
backdrop.style.display = 'none';
backdrop.setAttribute('aria-hidden', 'true');
}
cancelBtn.addEventListener('click', closeModal);
backdrop.addEventListener('click', function (e) {
if (e.target === backdrop) closeModal();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && backdrop.style.display !== 'none') closeModal();
});
// ===== Ładowanie danych =====
function loadData() {
var integrationId = state.integrationId;
var productId = state.productId;
// Pobierz kategorie i aktualne kategorie produktu równolegle
var categoriesPromise;
if (state.cachedIntegrationId === integrationId && state.allCategories !== null) {
categoriesPromise = Promise.resolve(state.allCategories);
} else {
categoriesPromise = fetch('/marketplace/' + integrationId + '/categories', {
headers: { 'Accept': 'application/json' }
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.ok) throw new Error(data.message || 'Błąd pobierania kategorii');
state.allCategories = data.categories;
state.cachedIntegrationId = integrationId;
return data.categories;
});
}
var productCategoriesPromise = fetch('/marketplace/' + integrationId + '/categories?_pc=1&external_product_id=' + productId + '&_method=product', {
headers: { 'Accept': 'application/json' }
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.ok) throw new Error(data.message || 'Błąd pobierania kategorii produktu');
return data.current_category_ids || [];
});
Promise.all([categoriesPromise, productCategoriesPromise])
.then(function (results) {
renderTree(results[0], results[1]);
})
.catch(function (err) {
showError(err.message || 'Nieznany błąd');
});
}
// ===== Renderowanie drzewka =====
function buildTree(flat) {
var map = {};
var roots = [];
flat.forEach(function (cat) {
map[cat.id] = { id: cat.id, parent_id: cat.parent_id, title: cat.title, children: [] };
});
flat.forEach(function (cat) {
if (cat.parent_id && map[cat.parent_id]) {
map[cat.parent_id].children.push(map[cat.id]);
} else {
roots.push(map[cat.id]);
}
});
return roots;
}
function renderNode(node, checkedIds) {
var li = document.createElement('li');
li.style.listStyle = 'none';
li.style.padding = '0';
var row = document.createElement('div');
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.gap = '6px';
row.style.padding = '3px 0';
// Toggle gałęzi
if (node.children.length > 0) {
var toggle = document.createElement('button');
toggle.type = 'button';
toggle.textContent = '▶';
toggle.style.cssText = 'background:none;border:none;cursor:pointer;font-size:10px;padding:0 2px;color:#666';
toggle.addEventListener('click', function () {
var childUl = li.querySelector('ul');
if (childUl) {
childUl.hidden = !childUl.hidden;
toggle.textContent = childUl.hidden ? '▶' : '▼';
}
});
row.appendChild(toggle);
} else {
var spacer = document.createElement('span');
spacer.style.display = 'inline-block';
spacer.style.width = '16px';
row.appendChild(spacer);
}
var label = document.createElement('label');
label.style.display = 'flex';
label.style.alignItems = 'center';
label.style.gap = '5px';
label.style.cursor = 'pointer';
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = String(node.id);
cb.name = 'category_ids[]';
cb.checked = checkedIds.indexOf(node.id) !== -1;
label.appendChild(cb);
label.appendChild(document.createTextNode(node.title));
row.appendChild(label);
li.appendChild(row);
if (node.children.length > 0) {
var ul = document.createElement('ul');
ul.style.paddingLeft = '20px';
ul.style.margin = '0';
node.children.forEach(function (child) {
ul.appendChild(renderNode(child, checkedIds));
});
li.appendChild(ul);
}
return li;
}
function renderTree(flat, checkedIds) {
var roots = buildTree(flat);
var ul = document.createElement('ul');
ul.style.padding = '0';
ul.style.margin = '0';
roots.forEach(function (root) {
ul.appendChild(renderNode(root, checkedIds));
});
treeEl.innerHTML = '';
if (roots.length === 0) {
treeEl.textContent = 'Brak dostępnych kategorii.';
} else {
treeEl.appendChild(ul);
}
loadingEl.style.display = 'none';
treeEl.style.display = '';
saveBtn.style.display = '';
}
function showError(msg) {
loadingEl.style.display = 'none';
errorEl.textContent = msg;
errorEl.style.display = '';
}
// ===== Zapis =====
saveBtn.addEventListener('click', function () {
var checkboxes = treeEl.querySelectorAll('input[type=checkbox]:checked');
var ids = [];
checkboxes.forEach(function (cb) {
var id = parseInt(cb.value, 10);
if (id > 0) ids.push(id);
});
saveBtn.disabled = true;
saveBtn.textContent = 'Zapisuję...';
fetch('/marketplace/' + state.integrationId + '/product/' + state.productId + '/categories', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ _token: csrfToken, category_ids: ids }),
})
.then(function (r) { return r.json(); })
.then(function (data) {
saveBtn.disabled = false;
saveBtn.textContent = 'Zapisz';
if (data.ok) {
closeModal();
if (window.OrderProAlerts) {
window.OrderProAlerts.show({ type: 'success', message: 'Kategorie zapisane.', timeout: 3000 });
}
} else {
if (window.OrderProAlerts) {
window.OrderProAlerts.show({ type: 'danger', message: data.message || 'Błąd zapisu.', timeout: 5000 });
}
}
})
.catch(function (err) {
saveBtn.disabled = false;
saveBtn.textContent = 'Zapisz';
if (window.OrderProAlerts) {
window.OrderProAlerts.show({ type: 'danger', message: 'Błąd sieci: ' + err.message, timeout: 5000 });
}
});
});
})();
</script>
```
**Uwaga do `productCategoriesPromise`:** Aktualny endpoint `GET /marketplace/{id}/categories` zwraca listę wszystkich kategorii. Potrzebujemy osobnego endpointu dla aktualnych kategorii produktu ALBO możemy użyć query stringa by wskazać produkt. Patrz Task 7 poniżej.
### Step 5: Commit
```bash
git add resources/views/marketplace/offers.php
git commit -m "feat: add category assignment column and modal to marketplace offers view"
```
---
## Task 7: Trzeci endpoint AJAX — aktualne kategorie produktu
W kroku Task 3 mamy 2 endpointy. Potrzebujemy trzeciego do pobierania aktualnych kategorii produktu z shopPRO.
**Files:**
- Modify: `C:\visual studio code\projekty\orderPRO\src\Modules\Marketplace\MarketplaceController.php`
- Modify: `C:\visual studio code\projekty\orderPRO\routes\web.php`
### Step 1: Dodaj metodę `productCategoriesJson()`
```php
public function productCategoriesJson(Request $request): Response
{
$integrationId = max(0, (int) $request->input('integration_id', 0));
$externalProductId = max(0, (int) $request->input('external_product_id', 0));
if ($integrationId <= 0 || $externalProductId <= 0) {
return Response::json(['ok' => false, 'message' => 'Brak wymaganych parametrów.'], 400);
}
$integration = $this->marketplace->findActiveIntegrationById($integrationId);
if ($integration === null) {
return Response::json(['ok' => false, 'message' => 'Integracja nie istnieje.'], 404);
}
$creds = $this->integrationRepository->findById($integrationId);
if ($creds === null) {
return Response::json(['ok' => false, 'message' => 'Brak danych uwierzytelniających.'], 404);
}
$result = $this->shopProClient->fetchProductById(
(string) ($creds['base_url'] ?? ''),
(string) ($creds['api_key'] ?? ''),
(int) ($creds['timeout_seconds'] ?? 10),
$externalProductId
);
if (!($result['ok'] ?? false)) {
return Response::json(['ok' => false, 'message' => $result['message']], 502);
}
$product = is_array($result['product'] ?? null) ? $result['product'] : [];
$categoryIds = isset($product['categories']) && is_array($product['categories'])
? array_values(array_filter(array_map('intval', $product['categories']), static fn(int $id): bool => $id > 0))
: [];
return Response::json(['ok' => true, 'current_category_ids' => $categoryIds]);
}
```
### Step 2: Dodaj trasę w `routes/web.php`
```php
$router->get('/marketplace/{integration_id}/product/{external_product_id}/categories', [$marketplaceController, 'productCategoriesJson'], [$authMiddleware]);
```
Jeśli router nie obsługuje dwóch parametrów w środku ścieżki, użyj:
```php
$router->get('/marketplace/{integration_id}/product-categories', [$marketplaceController, 'productCategoriesJson'], [$authMiddleware]);
```
I zaktualizuj URL w JS (`productCategoriesPromise`) odpowiednio.
### Step 3: Zaktualizuj JS w `offers.php` — URL dla `productCategoriesPromise`
Zmień URL w fetch:
```js
var productCategoriesPromise = fetch(
'/marketplace/' + integrationId + '/product/' + productId + '/categories',
{ headers: { 'Accept': 'application/json' } }
)
```
(lub `/product-categories?external_product_id=` jeśli router nie obsługuje dwóch parametrów)
### Step 4: Commit
```bash
git add src/Modules/Marketplace/MarketplaceController.php routes/web.php resources/views/marketplace/offers.php
git commit -m "feat: add productCategoriesJson endpoint and fix JS fetch URL"
```
---
## Task 8: Sprawdź router — obsługa parametrów URL
**Files:**
- Read: `C:\visual studio code\projekty\orderPRO\src\Core\Router.php` (lub podobna ścieżka)
Otwórz plik routera i sprawdź:
1. Jak są przetwarzane segmenty `{param}` — czy obsługuje wiele parametrów w jednej trasie
2. Jak parametry trafiają do `Request` — przez `$request->input('param_name')` czy `$request->attributes`
Jeśli router **nie obsługuje** tras w stylu `/marketplace/{id}/product/{pid}/categories` (dwa parametry dynamic), wybierz alternatywę:
```
GET /marketplace/{integration_id}/product-categories?external_product_id={pid}
POST /marketplace/{integration_id}/product-categories (body: {external_product_id, category_ids, _token})
```
Zaktualizuj odpowiednio routing, metody kontrolera i JS.
---
## Task 9: Tłumaczenia w `pl.php`
**Files:**
- Modify: `C:\visual studio code\projekty\orderPRO\resources\lang\pl.php`
Dodaj klucze do tablicy `'marketplace'`:
```php
'fields' => [
// ... istniejące ...
'categories' => 'Kategorie',
],
'actions' => [
// ... istniejące ...
'assign_categories' => 'Przypisz kategorie',
],
'category_modal' => [
'title' => 'Przypisz kategorie',
'loading' => 'Ładowanie kategorii...',
'no_categories' => 'Brak dostępnych kategorii.',
'save' => 'Zapisz',
'cancel' => 'Anuluj',
'saving' => 'Zapisuję...',
'saved' => 'Kategorie zapisane.',
'error_save' => 'Błąd zapisu.',
'error_network' => 'Błąd sieci.',
],
```
### Step 2: Commit
```bash
git add resources/lang/pl.php
git commit -m "feat: add category assignment translation keys"
```
---
## Task 10: Weryfikacja end-to-end
### Checklist testów manualnych
1. Otwórz `https://orderpro.projectpro.pl/marketplace/1`
2. Sprawdź czy tabela ma nową kolumnę "Kategorie"
3. Kliknij "Przypisz kategorie" przy dowolnym produkcie
4. Sprawdź: modal otwiera się, spinner "Ładowanie kategorii..." widoczny
5. Sprawdź: drzewo kategorii pojawia się z rozwijanymi gałęziami
6. Sprawdź: kategorie już przypisane do produktu są wstępnie zaznaczone
7. Zaznacz/odznacz kilka kategorii, kliknij "Zapisz"
8. Sprawdź: toast "Kategorie zapisane." pojawia się, modal zamknięty
9. Otwórz modal ponownie — sprawdź czy zaznaczone kategorie są aktualne
10. Sprawdź DevTools Network — żadna odpowiedź nie może zawierać klucza API
### Obsługa błędów
- Jeśli shopPRO niedostępny → modal pokazuje alert z komunikatem błędu
- Jeśli CSRF wygasł → odpowiedź 403 → toast "Nieprawidłowy token CSRF."
- Jeśli produkt nie istnieje w shopPRO → toast z błędem API
### Commit końcowy
```bash
cd "C:\visual studio code\projekty\orderPRO"
git add -A
git commit -m "feat: marketplace category assignment complete"
```
---
## Ważne: Kluczowe ustalenia
- **Router** obsługuje wiele parametrów `{param}` w jednej ścieżce — trasy jak `/marketplace/{id}/product/{pid}/categories` działają
- **IntegrationRepository**: używaj `findApiCredentials(int $id)` (nie `findById()`) — tylko ta metoda zwraca odszyfrowany `api_key`
- **`productCategoriesJson()`** w Task 7 też musi używać `findApiCredentials()`

View File

@@ -1,73 +0,0 @@
# Design: Per-Integration Product Content
**Date:** 2026-02-27
**Status:** Approved
## Summary
Products need separate `name`, `short_description`, and `description` for each integration. Global values in `product_translations` remain the fallback. Integration-specific overrides are stored in a new table.
## Model
**Global + override per integration:**
- `product_translations` stays as the global/base content (unchanged)
- New table `product_integration_translations` stores per-integration overrides
- NULL field = use global value
- When exporting to a specific integration, prefer integration-specific content, fall back to global
## Database
New migration file: `20260227_000014_create_product_integration_translations.sql`
```sql
CREATE TABLE product_integration_translations (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_id INT UNSIGNED NOT NULL,
integration_id INT UNSIGNED NOT NULL,
name VARCHAR(255) NULL,
short_description TEXT NULL,
description LONGTEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY pit_product_integration_unique (product_id, integration_id),
CONSTRAINT pit_product_fk FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE,
CONSTRAINT pit_integration_fk FOREIGN KEY (integration_id) REFERENCES integrations(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
Data migration: For all products currently linked to the "marianek.pl" integration via `product_channel_map`, copy `name`, `short_description`, `description` from `product_translations` to `product_integration_translations`.
## Import Flow
In `SettingsController::importExternalProductById`:
1. Save to `product_translations` as now (global, unchanged)
2. Additionally upsert `name`, `short_description`, `description` to `product_integration_translations` for the current `integration_id`
## Repository
New methods in `ProductRepository`:
- `findIntegrationTranslations(int $productId): array` — returns all per-integration translation rows for a product
- `upsertIntegrationTranslation(int $productId, int $integrationId, string|null $name, string|null $shortDescription, string|null $description): void`
## Edit UI
In `products/edit.php`, the Name/Short description/Description section gets tabs at the top:
```
[ Globalna ] [ marianek.pl ] [ inny sklep... ]
```
- Each tab shows: Nazwa, Krótki opis, Opis (WYSIWYG with Quill)
- "Globalna" tab = existing global fields (`name`, `short_description`, `description`)
- Integration tabs = per-integration overrides (`integration_content[{id}][name]`, etc.)
- Rest of the form (prices, SKU, images, meta) is global — no tabs
## Controller Changes
`ProductsController`:
- `edit` action: load active integrations + `findIntegrationTranslations($id)`, pass to view
- `update` action: process `integration_content[{id}]` array, call `upsertIntegrationTranslation` for each
## Existing Products Migration
One-off SQL script assigns existing product content to "marianek.pl" integration. All products in `product_channel_map` linked to the marianek.pl integration get their current `product_translations` content copied to `product_integration_translations`.

View File

@@ -1,600 +0,0 @@
# Per-Integration Product Content Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Store separate `name`, `short_description`, and `description` per integration (shopPRO instance), with global `product_translations` as fallback.
**Architecture:** New table `product_integration_translations (product_id, integration_id, name, short_description, description)` stores overrides. Import saves content to both global and per-integration tables. Edit form shows tabs: Globalna | per-integration.
**Tech Stack:** PHP 8.4, MariaDB, vanilla JS (Quill WYSIWYG already loaded on edit page)
---
### Task 1: Database migration — create table
**Files:**
- Create: `database/migrations/20260227_000014_create_product_integration_translations.sql`
**Step 1: Create the migration file**
```sql
CREATE TABLE IF NOT EXISTS product_integration_translations (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_id INT UNSIGNED NOT NULL,
integration_id INT UNSIGNED NOT NULL,
name VARCHAR(255) NULL,
short_description TEXT NULL,
description LONGTEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY pit_product_integration_unique (product_id, integration_id),
KEY pit_product_idx (product_id),
KEY pit_integration_idx (integration_id),
CONSTRAINT pit_product_fk
FOREIGN KEY (product_id) REFERENCES products(id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT pit_integration_fk
FOREIGN KEY (integration_id) REFERENCES integrations(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Migrate existing products to marianek.pl integration.
-- Finds the integration by name 'marianek.pl' and copies current
-- product_translations content for all linked products.
INSERT INTO product_integration_translations
(product_id, integration_id, name, short_description, description, created_at, updated_at)
SELECT
pt.product_id,
i.id AS integration_id,
pt.name,
pt.short_description,
pt.description,
NOW(),
NOW()
FROM product_translations pt
INNER JOIN product_channel_map pcm ON pcm.product_id = pt.product_id
INNER JOIN integrations i ON i.id = pcm.integration_id
WHERE i.name = 'marianek.pl'
AND pt.lang = 'pl'
ON DUPLICATE KEY UPDATE
name = VALUES(name),
short_description = VALUES(short_description),
description = VALUES(description),
updated_at = VALUES(updated_at);
```
**Step 2: Run the migration via settings panel**
Navigate to `/settings/database` and run pending migrations, or trigger via the app's migration runner. Verify table exists:
```sql
SHOW TABLES LIKE 'product_integration_translations';
SELECT COUNT(*) FROM product_integration_translations;
```
**Step 3: Commit**
```bash
git add database/migrations/20260227_000014_create_product_integration_translations.sql
git commit -m "feat: add product_integration_translations table and migrate marianek.pl data"
```
---
### Task 2: ProductRepository — two new methods
**Files:**
- Modify: `src/Modules/Products/ProductRepository.php`
**Step 1: Add `findIntegrationTranslations` method**
Add after the `findImagesByProductId` method (around line 250):
```php
/**
* @return array<int, array<string, mixed>>
*/
public function findIntegrationTranslations(int $productId): array
{
$stmt = $this->pdo->prepare(
'SELECT pit.id, pit.product_id, pit.integration_id,
pit.name, pit.short_description, pit.description,
i.name AS integration_name
FROM product_integration_translations pit
INNER JOIN integrations i ON i.id = pit.integration_id
WHERE pit.product_id = :product_id
ORDER BY i.name ASC'
);
$stmt->execute(['product_id' => $productId]);
$rows = $stmt->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map(static fn (array $row): array => [
'id' => (int) ($row['id'] ?? 0),
'product_id' => (int) ($row['product_id'] ?? 0),
'integration_id' => (int) ($row['integration_id'] ?? 0),
'integration_name' => (string) ($row['integration_name'] ?? ''),
'name' => isset($row['name']) ? (string) $row['name'] : null,
'short_description' => isset($row['short_description']) ? (string) $row['short_description'] : null,
'description' => isset($row['description']) ? (string) $row['description'] : null,
], $rows);
}
```
**Step 2: Add `upsertIntegrationTranslation` method**
Add immediately after the method above:
```php
public function upsertIntegrationTranslation(
int $productId,
int $integrationId,
?string $name,
?string $shortDescription,
?string $description
): void {
$now = date('Y-m-d H:i:s');
$stmt = $this->pdo->prepare(
'INSERT INTO product_integration_translations
(product_id, integration_id, name, short_description, description, created_at, updated_at)
VALUES
(:product_id, :integration_id, :name, :short_description, :description, :created_at, :updated_at)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
short_description = VALUES(short_description),
description = VALUES(description),
updated_at = VALUES(updated_at)'
);
$stmt->execute([
'product_id' => $productId,
'integration_id' => $integrationId,
'name' => $name !== '' ? $name : null,
'short_description' => $shortDescription !== '' ? $shortDescription : null,
'description' => $description !== '' ? $description : null,
'created_at' => $now,
'updated_at' => $now,
]);
}
```
**Step 3: Commit**
```bash
git add src/Modules/Products/ProductRepository.php
git commit -m "feat: add findIntegrationTranslations and upsertIntegrationTranslation to ProductRepository"
```
---
### Task 3: SettingsController — save per-integration content on import
**Files:**
- Modify: `src/Modules/Settings/SettingsController.php`
The import flow is in `importExternalProductById` (line ~677). After the transaction commits (line ~783), `$savedProductId` and `$integrationId` are both set.
**Step 1: Inject ProductRepository into SettingsController**
Check the constructor of `SettingsController`. Add `ProductRepository` as a dependency if it is not already present. Look for the constructor and add:
```php
use App\Modules\Products\ProductRepository;
```
And in the constructor parameter list:
```php
private readonly ProductRepository $products,
```
If `$this->products` already exists (check the constructor), skip adding it — just use the existing reference.
**Step 2: Add upsert call after transaction commit in `importExternalProductById`**
Locate the block after `$this->pdo->commit();` (around line 783). Add the upsert call inside the try block, before the commit:
```php
// Save per-integration content override
if ($integrationId > 0) {
$this->products->upsertIntegrationTranslation(
$savedProductId,
$integrationId,
$normalized['translation']['name'] ?? null,
$normalized['translation']['short_description'] ?? null,
$normalized['translation']['description'] ?? null
);
}
```
Place this BEFORE `$this->pdo->commit()` so it's inside the transaction.
**Step 3: Commit**
```bash
git add src/Modules/Settings/SettingsController.php
git commit -m "feat: save per-integration name/short_description/description on product import"
```
---
### Task 4: ProductsController — load per-integration data for edit
**Files:**
- Modify: `src/Modules/Products/ProductsController.php`
**Step 1: Update the `edit` action (line ~186)**
Find the block that builds data for the edit view. Currently it passes `form`, `productImages`, etc. Add two new variables:
```php
$activeIntegrations = $this->integrations->listByType('shoppro');
$integrationTranslations = $this->products->findIntegrationTranslations($id);
// Index integration translations by integration_id for easy lookup in view
$integrationTranslationsMap = [];
foreach ($integrationTranslations as $it) {
$integrationTranslationsMap[(int) $it['integration_id']] = $it;
}
```
Add them to the `render()` call:
```php
'activeIntegrations' => $activeIntegrations,
'integrationTranslationsMap' => $integrationTranslationsMap,
```
**Step 2: Commit**
```bash
git add src/Modules/Products/ProductsController.php
git commit -m "feat: pass active integrations and per-integration translations to product edit view"
```
---
### Task 5: ProductsController — save per-integration content on update
**Files:**
- Modify: `src/Modules/Products/ProductsController.php`
**Step 1: Update the `update` action (line ~416)**
After the successful `$this->service->update(...)` call (and before the redirect), add:
```php
// Save per-integration content overrides
$integrationContent = $request->input('integration_content', []);
if (is_array($integrationContent)) {
foreach ($integrationContent as $rawIntegrationId => $content) {
$integrationId = (int) $rawIntegrationId;
if ($integrationId <= 0 || !is_array($content)) {
continue;
}
$this->products->upsertIntegrationTranslation(
$id,
$integrationId,
isset($content['name']) ? trim((string) $content['name']) : null,
isset($content['short_description']) ? trim((string) $content['short_description']) : null,
isset($content['description']) ? trim((string) $content['description']) : null
);
}
}
```
Place this block AFTER the image changes block and BEFORE the success Flash/redirect.
**Step 2: Commit**
```bash
git add src/Modules/Products/ProductsController.php
git commit -m "feat: save per-integration content overrides on product update"
```
---
### Task 6: Edit view — content tabs UI
**Files:**
- Modify: `resources/views/products/edit.php`
**Step 1: Replace the static name/short_description/description fields with a tabbed section**
Current structure (around line 20-25 for name, and lines 111-125 for descriptions):
```php
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.name')) ?></span>
<input class="form-control" type="text" name="name" required value="...">
</label>
```
And:
```php
<div class="form-field mt-16"> <!-- short_description -->
<div class="form-field mt-12"> <!-- description -->
```
**New structure:** wrap name + short_description + description in a tabbed card. Add this BEFORE the `<div class="form-grid">` (the existing grid with SKU, EAN etc.), replacing the name field in the grid:
Remove the `name` label from `form-grid` and create a new card section above it:
```php
<?php
$activeIntegrations = is_array($activeIntegrations ?? null) ? $activeIntegrations : [];
$integrationTranslationsMap = is_array($integrationTranslationsMap ?? null) ? $integrationTranslationsMap : [];
?>
<div class="content-tabs-card mt-0">
<div class="content-tabs-nav" id="content-tabs-nav">
<button type="button" class="content-tab-btn is-active" data-tab="global">
<?= $e($t('products.content_tabs.global')) ?>
</button>
<?php foreach ($activeIntegrations as $integration): ?>
<?php $intId = (int) ($integration['id'] ?? 0); ?>
<?php if ($intId <= 0) continue; ?>
<button type="button" class="content-tab-btn" data-tab="integration-<?= $e((string) $intId) ?>">
<?= $e((string) ($integration['name'] ?? '#' . $intId)) ?>
</button>
<?php endforeach; ?>
</div>
<!-- GLOBAL TAB -->
<div class="content-tab-panel is-active" id="content-tab-global">
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.name')) ?> *</span>
<input class="form-control" type="text" name="name" required value="<?= $e((string) ($form['name'] ?? '')) ?>">
</label>
<div class="form-field mt-12">
<span class="field-label"><?= $e($t('products.fields.short_description')) ?></span>
<div class="wysiwyg-wrap">
<div id="editor-short-description"></div>
</div>
<textarea name="short_description" id="input-short-description" style="display:none"><?= $e((string) ($form['short_description'] ?? '')) ?></textarea>
</div>
<div class="form-field mt-12">
<span class="field-label"><?= $e($t('products.fields.description')) ?></span>
<div class="wysiwyg-wrap" style="--editor-min-height:180px">
<div id="editor-description"></div>
</div>
<textarea name="description" id="input-description" style="display:none"><?= $e((string) ($form['description'] ?? '')) ?></textarea>
</div>
</div>
<!-- PER-INTEGRATION TABS -->
<?php foreach ($activeIntegrations as $integration): ?>
<?php
$intId = (int) ($integration['id'] ?? 0);
if ($intId <= 0) continue;
$intData = $integrationTranslationsMap[$intId] ?? [];
$intName = isset($intData['name']) ? (string) $intData['name'] : '';
$intShort = isset($intData['short_description']) ? (string) $intData['short_description'] : '';
$intDesc = isset($intData['description']) ? (string) $intData['description'] : '';
?>
<div class="content-tab-panel" id="content-tab-integration-<?= $e((string) $intId) ?>">
<p class="muted" style="margin-bottom:8px">
Puste pole = używana wartość globalna.
</p>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.name')) ?></span>
<input class="form-control" type="text"
name="integration_content[<?= $e((string) $intId) ?>][name]"
value="<?= $e($intName) ?>">
</label>
<div class="form-field mt-12">
<span class="field-label"><?= $e($t('products.fields.short_description')) ?></span>
<div class="wysiwyg-wrap">
<div id="editor-int-short-<?= $e((string) $intId) ?>"></div>
</div>
<textarea name="integration_content[<?= $e((string) $intId) ?>][short_description]"
id="input-int-short-<?= $e((string) $intId) ?>"
style="display:none"><?= $e($intShort) ?></textarea>
</div>
<div class="form-field mt-12">
<span class="field-label"><?= $e($t('products.fields.description')) ?></span>
<div class="wysiwyg-wrap" style="--editor-min-height:180px">
<div id="editor-int-desc-<?= $e((string) $intId) ?>"></div>
</div>
<textarea name="integration_content[<?= $e((string) $intId) ?>][description]"
id="input-int-desc-<?= $e((string) $intId) ?>"
style="display:none"><?= $e($intDesc) ?></textarea>
</div>
</div>
<?php endforeach; ?>
</div>
```
**Step 2: Update the Quill initialization script at the bottom of edit.php**
The current script initializes `quillShort` and `quillDesc` for global fields. Extend it to also initialize editors for each per-integration tab, and sync all on form submit:
```js
// --- existing global editors ---
var quillShort = new Quill('#editor-short-description', { theme: 'snow', modules: { toolbar: toolbarShort } });
var quillDesc = new Quill('#editor-description', { theme: 'snow', modules: { toolbar: toolbarFull } });
if (shortInput && shortInput.value) quillShort.clipboard.dangerouslyPasteHTML(shortInput.value);
if (descInput && descInput.value) quillDesc.clipboard.dangerouslyPasteHTML(descInput.value);
// --- per-integration editors ---
var intEditors = []; // array of {shortQuill, descQuill, shortInput, descInput}
document.querySelectorAll('[id^="editor-int-short-"]').forEach(function(el) {
var suffix = el.id.replace('editor-int-short-', '');
var shortEl = el;
var descEl = document.getElementById('editor-int-desc-' + suffix);
var shortInp = document.getElementById('input-int-short-' + suffix);
var descInp = document.getElementById('input-int-desc-' + suffix);
if (!shortEl || !descEl || !shortInp || !descInp) return;
var qShort = new Quill(shortEl, { theme: 'snow', modules: { toolbar: toolbarShort } });
var qDesc = new Quill(descEl, { theme: 'snow', modules: { toolbar: toolbarFull } });
if (shortInp.value) qShort.clipboard.dangerouslyPasteHTML(shortInp.value);
if (descInp.value) qDesc.clipboard.dangerouslyPasteHTML(descInp.value);
intEditors.push({ shortQuill: qShort, descQuill: qDesc, shortInput: shortInp, descInput: descInp });
});
// --- sync all on submit ---
var form = document.querySelector('.product-form');
if (form) {
form.addEventListener('submit', function() {
if (shortInput) shortInput.value = quillShort.root.innerHTML;
if (descInput) descInput.value = quillDesc.root.innerHTML;
intEditors.forEach(function(e) {
e.shortInput.value = e.shortQuill.root.innerHTML;
e.descInput.value = e.descQuill.root.innerHTML;
});
});
}
```
**Step 3: Commit**
```bash
git add resources/views/products/edit.php
git commit -m "feat: add per-integration content tabs to product edit form"
```
---
### Task 7: Tab switching CSS + JS
**Files:**
- Modify: `resources/scss/app.scss`
- JS inline in `resources/views/products/edit.php`
**Step 1: Add tab styles to app.scss**
```scss
.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 transparent;
margin-bottom: -2px;
border-radius: 4px 4px 0 0;
transition: color 0.15s, border-color 0.15s;
&:hover {
color: var(--c-text-strong, #111827);
}
&.is-active {
color: var(--c-primary, #2563eb);
border-bottom-color: var(--c-primary, #2563eb);
}
}
.content-tab-panel {
display: none;
&.is-active {
display: block;
}
}
```
**Step 2: Add tab-switching JS in edit.php (inline, after the Quill script)**
```js
(function() {
var nav = document.getElementById('content-tabs-nav');
if (!nav) return;
nav.addEventListener('click', function(e) {
var btn = e.target.closest('.content-tab-btn');
if (!btn) return;
var tabId = btn.getAttribute('data-tab');
if (!tabId) return;
// deactivate all
nav.querySelectorAll('.content-tab-btn').forEach(function(b) {
b.classList.remove('is-active');
});
document.querySelectorAll('.content-tab-panel').forEach(function(p) {
p.classList.remove('is-active');
});
// activate selected
btn.classList.add('is-active');
var panel = document.getElementById('content-tab-' + tabId);
if (panel) panel.classList.add('is-active');
});
})();
```
**Step 3: Rebuild CSS**
```bash
cd "C:/visual studio code/projekty/orderPRO" && npm run build:css
```
**Step 4: Commit**
```bash
git add resources/scss/app.scss resources/views/products/edit.php public/assets/css/app.css
git commit -m "feat: tab switching styles and JS for per-integration content"
```
---
### Task 8: Add translations key
**Files:**
- Modify: `resources/lang/pl.php`
**Step 1: Add `content_tabs` key under `products`**
Find the `products` array and add:
```php
'content_tabs' => [
'global' => 'Globalna',
],
```
**Step 2: Commit**
```bash
git add resources/lang/pl.php
git commit -m "feat: add content_tabs translation key"
```
---
### Task 9: Manual smoke test
1. Navigate to `/settings/database` → run pending migrations → confirm `product_integration_translations` exists and has rows for marianek.pl products
2. Navigate to `/products/edit?id=32` → confirm tabs appear: "Globalna" and "marianek.pl"
3. Switch tabs → confirm fields toggle correctly
4. Edit marianek.pl tab name/description → Save → confirm saved in DB:
```sql
SELECT * FROM product_integration_translations WHERE product_id = 32;
```
5. Import a product from shopPRO → confirm row created in `product_integration_translations`
6. Verify global fields unchanged after editing integration tab

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
use App\Core\Database\ConnectionFactory;
use App\Core\Support\Env;
use App\Modules\Cron\CronJobProcessor;
use App\Modules\Cron\CronJobRepository;
use App\Modules\Cron\CronJobType;
use App\Modules\Cron\ProductLinksHealthCheckHandler;
use App\Modules\Cron\ShopProOrderStatusSyncHandler;
use App\Modules\Cron\ShopProOrdersImportHandler;
use App\Modules\Cron\ShopProOfferTitlesRefreshHandler;
use App\Modules\Orders\OrderImportService;
use App\Modules\Orders\OrderStatusSyncService;
use App\Modules\Orders\OrdersRepository;
use App\Modules\ProductLinks\ChannelOffersRepository;
use App\Modules\ProductLinks\OfferImportService;
use App\Modules\ProductLinks\ProductLinksRepository;
use App\Modules\Settings\IntegrationRepository;
use App\Modules\Settings\OrderStatusMappingRepository;
use App\Modules\Settings\ShopProClient;
$basePath = dirname(__DIR__);
$vendorAutoload = $basePath . '/vendor/autoload.php';
if (is_file($vendorAutoload)) {
require $vendorAutoload;
} else {
spl_autoload_register(static function (string $class) use ($basePath): void {
$prefix = 'App\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relative = substr($class, strlen($prefix));
$file = $basePath . '/src/' . str_replace('\\', '/', $relative) . '.php';
if (is_file($file)) {
require $file;
}
});
}
Env::load($basePath . '/.env');
/** @var array<string, mixed> $dbConfig */
$dbConfig = require $basePath . '/config/database.php';
/** @var array<string, mixed> $appConfig */
$appConfig = require $basePath . '/config/app.php';
$limit = 20;
foreach ($argv as $argument) {
if (!str_starts_with((string) $argument, '--limit=')) {
continue;
}
$limitValue = (int) substr((string) $argument, strlen('--limit='));
if ($limitValue > 0) {
$limit = min(200, $limitValue);
}
}
try {
$pdo = ConnectionFactory::make($dbConfig);
$cronJobs = new CronJobRepository($pdo);
$processor = new CronJobProcessor($cronJobs);
$integrationRepository = new IntegrationRepository(
$pdo,
(string) (($appConfig['integrations']['secret'] ?? '') ?: '')
);
$offersRepository = new ChannelOffersRepository($pdo);
$linksRepository = new ProductLinksRepository($pdo);
$shopProClient = new ShopProClient();
$offerImportService = new OfferImportService($shopProClient, $offersRepository, $pdo);
$linksHealthCheckHandler = new ProductLinksHealthCheckHandler(
$integrationRepository,
$offerImportService,
$linksRepository,
$offersRepository
);
$offerTitlesRefreshHandler = new ShopProOfferTitlesRefreshHandler(
$integrationRepository,
$offerImportService
);
$ordersRepository = new OrdersRepository($pdo);
$orderImportService = new OrderImportService(
$integrationRepository,
$ordersRepository,
$shopProClient,
$pdo
);
$orderStatusMappings = new OrderStatusMappingRepository($pdo);
$orderStatusSyncService = new OrderStatusSyncService(
$integrationRepository,
$ordersRepository,
$orderStatusMappings,
$shopProClient,
$pdo
);
$ordersImportHandler = new ShopProOrdersImportHandler($orderImportService);
$orderStatusSyncHandler = new ShopProOrderStatusSyncHandler($orderStatusSyncService);
$processor->registerHandler(CronJobType::PRODUCT_LINKS_HEALTH_CHECK, $linksHealthCheckHandler);
$processor->registerHandler(CronJobType::SHOPPRO_ORDERS_IMPORT, $ordersImportHandler);
$processor->registerHandler(CronJobType::SHOPPRO_ORDER_STATUS_SYNC, $orderStatusSyncHandler);
$processor->registerHandler(CronJobType::SHOPPRO_OFFER_TITLES_REFRESH, $offerTitlesRefreshHandler);
$result = $processor->run($limit);
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL;
} catch (\Throwable $exception) {
fwrite(STDERR, '[error] ' . $exception->getMessage() . PHP_EOL);
exit(1);
}

View File

@@ -0,0 +1,28 @@
<div class="orders-page">
<section class="card orders-head">
<div class="page-head">
<div>
<h1><?= $e($t('orders.title')) ?></h1>
<p class="muted"><?= $e($t('orders.description')) ?></p>
</div>
</div>
</section>
<?php if (!empty($errorMessage)): ?>
<section class="card mt-16">
<div class="alert alert--danger" role="alert">
<?= $e((string) $errorMessage) ?>
</div>
</section>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<section class="card mt-16">
<div class="alert alert--success" role="status">
<?= $e((string) $successMessage) ?>
</div>
</section>
<?php endif; ?>
<?php require __DIR__ . '/../components/table-list.php'; ?>
</div>

View File

@@ -12,6 +12,7 @@ $webLimit = max(1, (int) ($webCronLimit ?? 5));
<nav class="settings-nav mt-16" aria-label="<?= $e($t('settings.submenu_label')) ?>">
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'database' ? ' is-active' : '' ?>" href="/settings/database"><?= $e($t('settings.database.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'order_statuses' ? ' is-active' : '' ?>" href="/settings/order-statuses"><?= $e($t('settings.order_statuses.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'cron' ? ' is-active' : '' ?>" href="/settings/cron"><?= $e($t('settings.cron.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'gs1' ? ' is-active' : '' ?>" href="/settings/gs1"><?= $e($t('settings.gs1.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'products' ? ' is-active' : '' ?>" href="/settings/products"><?= $e($t('settings.products.title')) ?></a>

View File

@@ -0,0 +1,100 @@
<?php
$migrationStatus = is_array($status ?? null) ? $status : [];
$pending = (int) ($migrationStatus['pending'] ?? 0);
$total = (int) ($migrationStatus['total'] ?? 0);
$applied = (int) ($migrationStatus['applied'] ?? 0);
$pendingFiles = (array) ($migrationStatus['pending_files'] ?? []);
$logs = (array) ($runLogs ?? []);
?>
<section class="card">
<h1><?= $e($t('settings.title')) ?></h1>
<p class="muted"><?= $e($t('settings.description')) ?></p>
<nav class="settings-nav mt-16" aria-label="<?= $e($t('settings.submenu_label')) ?>">
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'database' ? ' is-active' : '' ?>" href="/settings/database"><?= $e($t('settings.database.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'order_statuses' ? ' is-active' : '' ?>" href="/settings/order-statuses"><?= $e($t('settings.order_statuses.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'cron' ? ' is-active' : '' ?>" href="/settings/cron"><?= $e($t('settings.cron.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'gs1' ? ' is-active' : '' ?>" href="/settings/gs1"><?= $e($t('settings.gs1.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'products' ? ' is-active' : '' ?>" href="/settings/products"><?= $e($t('settings.products.title')) ?></a>
</nav>
</section>
<section class="card mt-16">
<h2 class="section-title"><?= $e($t('settings.database.title')) ?></h2>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert">
<?= $e((string) $errorMessage) ?>
</div>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<div class="alert alert--success mt-12" role="status">
<?= $e((string) $successMessage) ?>
</div>
<?php endif; ?>
<div class="settings-grid mt-16">
<div class="settings-stat">
<span class="settings-stat__label"><?= $e($t('settings.database.stats.total')) ?></span>
<strong class="settings-stat__value"><?= $e((string) $total) ?></strong>
</div>
<div class="settings-stat">
<span class="settings-stat__label"><?= $e($t('settings.database.stats.applied')) ?></span>
<strong class="settings-stat__value"><?= $e((string) $applied) ?></strong>
</div>
<div class="settings-stat">
<span class="settings-stat__label"><?= $e($t('settings.database.stats.pending')) ?></span>
<strong class="settings-stat__value"><?= $e((string) $pending) ?></strong>
</div>
</div>
<?php if ($pending > 0): ?>
<div class="alert alert--warning mt-16" role="status">
<?= $e($t('settings.database.state.needs_update')) ?>
</div>
<form class="mt-16" action="/settings/database/migrate" method="post">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.database.actions.run_update')) ?></button>
</form>
<?php else: ?>
<div class="alert alert--success mt-16" role="status">
<?= $e($t('settings.database.state.up_to_date')) ?>
</div>
<?php endif; ?>
</section>
<section class="card mt-16">
<h2 class="section-title"><?= $e($t('settings.database.pending_files_title')) ?></h2>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th><?= $e($t('settings.database.fields.filename')) ?></th>
</tr>
</thead>
<tbody>
<?php if (empty($pendingFiles)): ?>
<tr>
<td class="muted"><?= $e($t('settings.database.pending_files_empty')) ?></td>
</tr>
<?php else: ?>
<?php foreach ($pendingFiles as $filename): ?>
<tr>
<td><?= $e((string) $filename) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<?php if (!empty($logs)): ?>
<section class="card mt-16">
<h2 class="section-title"><?= $e($t('settings.database.last_run_logs')) ?></h2>
<pre class="settings-logs mt-12"><?php foreach ($logs as $line): ?><?= $e((string) $line) . "\n" ?><?php endforeach; ?></pre>
</section>
<?php endif; ?>

View File

@@ -4,6 +4,7 @@
<nav class="settings-nav mt-16" aria-label="<?= $e($t('settings.submenu_label')) ?>">
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'database' ? ' is-active' : '' ?>" href="/settings/database"><?= $e($t('settings.database.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'order_statuses' ? ' is-active' : '' ?>" href="/settings/order-statuses"><?= $e($t('settings.order_statuses.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'cron' ? ' is-active' : '' ?>" href="/settings/cron"><?= $e($t('settings.cron.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'gs1' ? ' is-active' : '' ?>" href="/settings/gs1"><?= $e($t('settings.gs1.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'products' ? ' is-active' : '' ?>" href="/settings/products"><?= $e($t('settings.products.title')) ?></a>

View File

@@ -12,6 +12,7 @@ $isEdit = ((int) ($formValues['integration_id'] ?? 0)) > 0;
<nav class="settings-nav mt-16" aria-label="<?= $e($t('settings.submenu_label')) ?>">
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'database' ? ' is-active' : '' ?>" href="/settings/database"><?= $e($t('settings.database.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'order_statuses' ? ' is-active' : '' ?>" href="/settings/order-statuses"><?= $e($t('settings.order_statuses.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'cron' ? ' is-active' : '' ?>" href="/settings/cron"><?= $e($t('settings.cron.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'gs1' ? ' is-active' : '' ?>" href="/settings/gs1"><?= $e($t('settings.gs1.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'products' ? ' is-active' : '' ?>" href="/settings/products"><?= $e($t('settings.products.title')) ?></a>
@@ -130,6 +131,24 @@ $isEdit = ((int) ($formValues['integration_id'] ?? 0)) > 0;
<span class="field-label"><?= $e($t('settings.integrations.fields.timeout_seconds')) ?></span>
<input class="form-control" type="number" min="3" max="60" name="timeout_seconds" value="<?= $e((string) ($formValues['timeout_seconds'] ?? '10')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.integrations.fields.orders_fetch_start_date')) ?></span>
<input class="form-control" type="date" name="orders_fetch_start_date" value="<?= $e((string) ($formValues['orders_fetch_start_date'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.integrations.fields.order_status_sync_direction')) ?></span>
<select class="form-control" name="order_status_sync_direction">
<?php $syncDirection = (string) ($formValues['order_status_sync_direction'] ?? 'shoppro_to_orderpro'); ?>
<option value="shoppro_to_orderpro"<?= $syncDirection === 'shoppro_to_orderpro' ? ' selected' : '' ?>>
<?= $e($t('settings.integrations.fields.order_status_sync_direction_shoppro_to_orderpro')) ?>
</option>
<option value="orderpro_to_shoppro"<?= $syncDirection === 'orderpro_to_shoppro' ? ' selected' : '' ?>>
<?= $e($t('settings.integrations.fields.order_status_sync_direction_orderpro_to_shoppro')) ?>
</option>
</select>
</label>
</div>
<label class="form-field mt-12">
@@ -139,6 +158,13 @@ $isEdit = ((int) ($formValues['integration_id'] ?? 0)) > 0;
</span>
</label>
<label class="form-field mt-12">
<span class="field-label">
<input type="checkbox" name="orders_fetch_enabled" value="1"<?= ((string) ($formValues['orders_fetch_enabled'] ?? '0')) === '1' ? ' checked' : '' ?>>
<?= $e($t('settings.integrations.fields.orders_fetch_enabled_checkbox')) ?>
</span>
</label>
<div class="form-actions mt-16">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.integrations.actions.save')) ?></button>
<a href="/settings/integrations/shoppro" class="btn btn--secondary"><?= $e($t('settings.integrations.actions.new')) ?></a>

View File

@@ -0,0 +1,107 @@
<?php
$integrationsList = is_array($integrations ?? null) ? $integrations : [];
$selectedIntegrationId = max(0, (int) ($selectedIntegrationId ?? 0));
$shopProStatusesList = is_array($shopProStatuses ?? null) ? $shopProStatuses : [];
$orderProOptions = is_array($orderProStatusOptions ?? null) ? $orderProStatusOptions : [];
?>
<section class="card">
<h1><?= $e($t('settings.title')) ?></h1>
<p class="muted"><?= $e($t('settings.description')) ?></p>
<nav class="settings-nav mt-16" aria-label="<?= $e($t('settings.submenu_label')) ?>">
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'database' ? ' is-active' : '' ?>" href="/settings/database"><?= $e($t('settings.database.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'order_statuses' ? ' is-active' : '' ?>" href="/settings/order-statuses"><?= $e($t('settings.order_statuses.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'cron' ? ' is-active' : '' ?>" href="/settings/cron"><?= $e($t('settings.cron.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'gs1' ? ' is-active' : '' ?>" href="/settings/gs1"><?= $e($t('settings.gs1.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'products' ? ' is-active' : '' ?>" href="/settings/products"><?= $e($t('settings.products.title')) ?></a>
</nav>
</section>
<section class="card mt-16">
<h2 class="section-title"><?= $e($t('settings.order_statuses.title')) ?></h2>
<p class="muted mt-12"><?= $e($t('settings.order_statuses.description')) ?></p>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<div class="alert alert--success mt-12" role="status"><?= $e((string) $successMessage) ?></div>
<?php endif; ?>
<form method="get" action="/settings/order-statuses" class="mt-16">
<label class="form-field">
<span class="field-label"><?= $e($t('settings.order_statuses.integration')) ?></span>
<select class="form-control" name="integration_id" onchange="this.form.submit()">
<?php if ($integrationsList === []): ?>
<option value="0"><?= $e($t('settings.order_statuses.no_integrations')) ?></option>
<?php else: ?>
<?php foreach ($integrationsList as $integration): ?>
<?php $id = max(0, (int) ($integration['id'] ?? 0)); ?>
<?php if ($id <= 0) continue; ?>
<option value="<?= $e((string) $id) ?>"<?= $id === $selectedIntegrationId ? ' selected' : '' ?>>
<?= $e((string) ($integration['name'] ?? ('#' . $id))) ?> (ID: <?= $e((string) $id) ?>)
</option>
<?php endforeach; ?>
<?php endif; ?>
</select>
</label>
</form>
<?php if ($selectedIntegrationId > 0): ?>
<form action="/settings/order-statuses/save" method="post" class="mt-16">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="integration_id" value="<?= $e((string) $selectedIntegrationId) ?>">
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th><?= $e($t('settings.order_statuses.fields.shoppro_code')) ?></th>
<th><?= $e($t('settings.order_statuses.fields.shoppro_name')) ?></th>
<th><?= $e($t('settings.order_statuses.fields.orderpro_status')) ?></th>
</tr>
</thead>
<tbody>
<?php if ($shopProStatusesList === []): ?>
<tr>
<td colspan="3" class="muted"><?= $e($t('settings.order_statuses.empty')) ?></td>
</tr>
<?php else: ?>
<?php foreach ($shopProStatusesList as $status): ?>
<?php
$shopCode = trim((string) ($status['code'] ?? ''));
if ($shopCode === '') continue;
$shopName = trim((string) ($status['name'] ?? $shopCode));
$selectedOrderPro = trim((string) ($status['mapped_orderpro_status'] ?? ''));
?>
<tr>
<td>
<?= $e($shopCode) ?>
<input type="hidden" name="shoppro_names[<?= $e($shopCode) ?>]" value="<?= $e($shopName) ?>">
</td>
<td><?= $e($shopName) ?></td>
<td>
<select class="form-control" name="mappings[<?= $e($shopCode) ?>]">
<option value=""><?= $e($t('settings.order_statuses.fields.no_mapping')) ?></option>
<?php foreach ($orderProOptions as $orderProCode => $orderProLabel): ?>
<option value="<?= $e((string) $orderProCode) ?>"<?= $selectedOrderPro === (string) $orderProCode ? ' selected' : '' ?>>
<?= $e((string) $orderProLabel) ?>
</option>
<?php endforeach; ?>
</select>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<div class="form-actions mt-16">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.order_statuses.actions.save')) ?></button>
</div>
</form>
<?php endif; ?>
</section>

View File

@@ -5,6 +5,7 @@
<nav class="settings-nav mt-16" aria-label="<?= $e($t('settings.submenu_label')) ?>">
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'database' ? ' is-active' : '' ?>" href="/settings/database"><?= $e($t('settings.database.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'order_statuses' ? ' is-active' : '' ?>" href="/settings/order-statuses"><?= $e($t('settings.order_statuses.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'cron' ? ' is-active' : '' ?>" href="/settings/cron"><?= $e($t('settings.cron.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'gs1' ? ' is-active' : '' ?>" href="/settings/gs1"><?= $e($t('settings.gs1.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'products' ? ' is-active' : '' ?>" href="/settings/products"><?= $e($t('settings.products.title')) ?></a>

View File

@@ -7,6 +7,8 @@ final class CronJobType
{
public const PRODUCT_LINKS_HEALTH_CHECK = 'product_links_health_check';
public const SHOPPRO_OFFER_TITLES_REFRESH = 'shoppro_offer_titles_refresh';
public const SHOPPRO_ORDERS_IMPORT = 'shoppro_orders_import';
public const SHOPPRO_ORDER_STATUS_SYNC = 'shoppro_order_status_sync';
public const PRIORITY_HIGH = 50;
public const PRIORITY_NORMAL = 100;
@@ -16,6 +18,8 @@ final class CronJobType
{
return match (trim($jobType)) {
self::PRODUCT_LINKS_HEALTH_CHECK => 110,
self::SHOPPRO_ORDERS_IMPORT => 90,
self::SHOPPRO_ORDER_STATUS_SYNC => 95,
self::SHOPPRO_OFFER_TITLES_REFRESH => 170,
default => self::PRIORITY_NORMAL,
};
@@ -25,6 +29,8 @@ final class CronJobType
{
return match (trim($jobType)) {
self::PRODUCT_LINKS_HEALTH_CHECK => 3,
self::SHOPPRO_ORDERS_IMPORT => 3,
self::SHOPPRO_ORDER_STATUS_SYNC => 3,
self::SHOPPRO_OFFER_TITLES_REFRESH => 3,
default => 3,
};

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Modules\Cron;
use App\Modules\Orders\OrderStatusSyncService;
final class ShopProOrderStatusSyncHandler
{
public function __construct(private readonly OrderStatusSyncService $syncService)
{
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $job
* @return array<string, mixed>
*/
public function __invoke(array $payload = [], array $job = []): array
{
return $this->syncService->sync($payload);
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Modules\Cron;
use App\Modules\Orders\OrderImportService;
final class ShopProOrdersImportHandler
{
public function __construct(private readonly OrderImportService $orderImportService)
{
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $job
* @return array<string, mixed>
*/
public function __invoke(array $payload = [], array $job = []): array
{
return $this->orderImportService->importOne($payload);
}
}

View File

@@ -0,0 +1,629 @@
<?php
declare(strict_types=1);
namespace App\Modules\Orders;
use App\Modules\Settings\IntegrationRepository;
use App\Modules\Settings\ShopProClient;
use PDO;
use Throwable;
final class OrderImportService
{
public function __construct(
private readonly IntegrationRepository $integrations,
private readonly OrdersRepository $orders,
private readonly ShopProClient $shopProClient,
private readonly PDO $pdo
) {
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function importOne(array $payload = []): array
{
$forcedIntegrationId = max(0, (int) ($payload['integration_id'] ?? 0));
$enabledIntegrations = array_values(array_filter(
$this->integrations->listByType('shoppro'),
static function (array $integration) use ($forcedIntegrationId): bool {
$id = (int) ($integration['id'] ?? 0);
if ($id <= 0) {
return false;
}
if ($forcedIntegrationId > 0 && $id !== $forcedIntegrationId) {
return false;
}
return ($integration['is_active'] ?? false) === true
&& ($integration['has_api_key'] ?? false) === true
&& ($integration['orders_fetch_enabled'] ?? false) === true;
}
));
if ($enabledIntegrations === []) {
return [
'ok' => true,
'message' => 'Brak aktywnych integracji z wlaczonym pobieraniem zamowien.',
'processed' => 0,
'checked_integrations' => 0,
'integration_failures' => 0,
'errors' => [],
];
}
$integrationFailures = 0;
$errors = [];
foreach ($enabledIntegrations as $integration) {
$integrationId = (int) ($integration['id'] ?? 0);
if ($integrationId <= 0) {
continue;
}
try {
$credentials = $this->integrations->findApiCredentials($integrationId);
} catch (Throwable $exception) {
$integrationFailures++;
$this->orders->touchSyncState($integrationId, $exception->getMessage());
if (count($errors) < 5) {
$errors[] = 'Integracja #' . $integrationId . ': ' . $exception->getMessage();
}
continue;
}
if ($credentials === null || trim((string) ($credentials['api_key'] ?? '')) === '') {
$integrationFailures++;
$message = 'Brak poprawnych danych API.';
$this->orders->touchSyncState($integrationId, $message);
if (count($errors) < 5) {
$errors[] = 'Integracja #' . $integrationId . ': ' . $message;
}
continue;
}
$state = $this->orders->findSyncState($integrationId);
$fromDate = $this->resolveFromDate(
$this->normalizeDateOnly((string) ($integration['orders_fetch_start_date'] ?? '')),
$this->normalizeDateTime((string) ($state['last_synced_external_updated_at'] ?? ''))
);
$fetch = $this->shopProClient->fetchOrders(
(string) ($credentials['base_url'] ?? ''),
(string) ($credentials['api_key'] ?? ''),
(int) ($credentials['timeout_seconds'] ?? 10),
1,
100,
$fromDate
);
if (($fetch['ok'] ?? false) !== true) {
$integrationFailures++;
$message = trim((string) ($fetch['message'] ?? 'Blad pobierania zamowien.'));
$this->orders->touchSyncState($integrationId, $message);
if (count($errors) < 5) {
$errors[] = 'Integracja #' . $integrationId . ': ' . $message;
}
continue;
}
$items = is_array($fetch['items'] ?? null) ? $fetch['items'] : [];
$candidates = $this->buildCandidates($items, $state);
if ($candidates === []) {
$this->orders->touchSyncState($integrationId, null);
continue;
}
$candidate = $candidates[0];
$sourcePayload = $candidate['payload'];
$detailsResult = $this->shopProClient->fetchOrderById(
(string) ($credentials['base_url'] ?? ''),
(string) ($credentials['api_key'] ?? ''),
(int) ($credentials['timeout_seconds'] ?? 10),
(string) ($candidate['external_order_id'] ?? '')
);
if (($detailsResult['ok'] ?? false) === true && is_array($detailsResult['order'] ?? null)) {
$sourcePayload = (array) $detailsResult['order'];
}
$mappedOrder = $this->mapOrder($sourcePayload);
$externalOrderId = (string) ($mappedOrder['external_order_id'] ?? '');
$externalUpdatedAt = (string) ($mappedOrder['external_updated_at'] ?? '');
if ($externalOrderId === '' || $externalUpdatedAt === '') {
$integrationFailures++;
$message = 'Nie mozna zidentyfikowac zamowienia (brak id albo daty aktualizacji).';
$this->orders->touchSyncState($integrationId, $message);
if (count($errors) < 5) {
$errors[] = 'Integracja #' . $integrationId . ': ' . $message;
}
continue;
}
try {
$this->pdo->beginTransaction();
$orderId = $this->orders->upsertOrder(
$integrationId,
$mappedOrder,
$sourcePayload
);
$this->orders->replaceOrderItems($orderId, $this->extractOrderItems($sourcePayload));
$this->orders->advanceSyncState($integrationId, $externalUpdatedAt, $externalOrderId);
$this->pdo->commit();
} catch (Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
$integrationFailures++;
$this->orders->touchSyncState($integrationId, $exception->getMessage());
if (count($errors) < 5) {
$errors[] = 'Integracja #' . $integrationId . ': ' . $exception->getMessage();
}
continue;
}
return [
'ok' => true,
'message' => 'Zaimportowano 1 zamowienie.',
'processed' => 1,
'integration_id' => $integrationId,
'external_order_id' => $externalOrderId,
'checked_integrations' => count($enabledIntegrations),
'integration_failures' => $integrationFailures,
'errors' => $errors,
];
}
return [
'ok' => $integrationFailures === 0,
'message' => $integrationFailures === 0
? 'Brak nowych zamowien do importu.'
: 'Import zamowien zakonczony z bledami integracji.',
'processed' => 0,
'checked_integrations' => count($enabledIntegrations),
'integration_failures' => $integrationFailures,
'errors' => $errors,
];
}
/**
* @param array<int, mixed> $items
* @param array<string, mixed>|null $state
* @return array<int, array{external_order_id:string,external_updated_at:string,payload:array<string, mixed>}>
*/
private function buildCandidates(array $items, ?array $state): array
{
$result = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$externalOrderId = $this->normalizeOrderId($this->readPath($item, [
'id',
'order_id',
'external_order_id',
]));
if ($externalOrderId === '') {
continue;
}
$externalUpdatedAt = $this->normalizeDateTime($this->readPath($item, [
'updated_at',
'date_updated',
'modified_at',
'date_modified',
'created_at',
'date_created',
]));
if ($externalUpdatedAt === null) {
continue;
}
if (!$this->isAfterCursor($externalUpdatedAt, $externalOrderId, $state)) {
continue;
}
$result[] = [
'external_order_id' => $externalOrderId,
'external_updated_at' => $externalUpdatedAt,
'payload' => $item,
];
}
usort($result, function (array $a, array $b): int {
$cmp = strcmp((string) ($a['external_updated_at'] ?? ''), (string) ($b['external_updated_at'] ?? ''));
if ($cmp !== 0) {
return $cmp;
}
return $this->compareOrderId((string) ($a['external_order_id'] ?? ''), (string) ($b['external_order_id'] ?? ''));
});
return $result;
}
/**
* @param array<string, mixed>|null $state
*/
private function isAfterCursor(string $externalUpdatedAt, string $externalOrderId, ?array $state): bool
{
if (!is_array($state)) {
return true;
}
$cursorUpdatedAt = $this->normalizeDateTime($state['last_synced_external_updated_at'] ?? null);
$cursorOrderId = $this->normalizeOrderId($state['last_synced_external_order_id'] ?? null);
if ($cursorUpdatedAt === null) {
return true;
}
$dateCmp = strcmp($externalUpdatedAt, $cursorUpdatedAt);
if ($dateCmp > 0) {
return true;
}
if ($dateCmp < 0) {
return false;
}
if ($cursorOrderId === '') {
return true;
}
return $this->compareOrderId($externalOrderId, $cursorOrderId) > 0;
}
private function compareOrderId(string $left, string $right): int
{
if (ctype_digit($left) && ctype_digit($right)) {
return (int) $left <=> (int) $right;
}
return strcmp($left, $right);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function mapOrder(array $payload): array
{
$externalCreatedAt = $this->normalizeDateTime($this->readPath($payload, [
'created_at',
'date_created',
'date_add',
'add_date',
'order_date',
]));
$externalUpdatedAt = $this->normalizeDateTime($this->readPath($payload, [
'updated_at',
'date_updated',
'date_upd',
'update_date',
'modified_at',
'date_modified',
'created_at',
'date_created',
'date_add',
]));
$buyerName = $this->nullableString($this->readPath($payload, [
'buyer.name',
'customer.name',
'client.name',
'user.name',
'user.full_name',
'user.fullname',
'buyer.full_name',
'customer.full_name',
'buyer.fullname',
'customer.fullname',
'buyer.first_name',
'buyer.firstname',
'customer.firstname',
'client.firstname',
'user.firstname',
]));
if ($buyerName === null) {
$buyerName = $this->buildFullName(
$this->nullableString($this->readPath($payload, [
'buyer.first_name',
'customer.first_name',
'client.first_name',
'user.firstname',
'user.first_name',
])),
$this->nullableString($this->readPath($payload, [
'buyer.last_name',
'buyer.lastname',
'customer.last_name',
'customer.lastname',
'client.last_name',
'client.lastname',
'user.lastname',
'user.last_name',
'surname',
]))
);
}
return [
'external_order_id' => $this->normalizeOrderId($this->readPath($payload, [
'id',
'order_id',
'external_order_id',
])),
'external_order_number' => $this->nullableString($this->readPath($payload, [
'order_number',
'number',
'full_number',
'id',
])),
'status' => $this->nullableString($this->readPath($payload, [
'status',
'order_status',
])),
'currency' => $this->nullableString($this->readPath($payload, [
'currency',
'currency_code',
'currency_symbol',
'price_currency',
'order_currency',
'payment.currency',
'summary.currency',
'totals.currency',
])),
'total_gross' => $this->nullableFloat($this->readPath($payload, [
'total_gross',
'total',
'sum',
'price_brutto',
'total_brutto',
'summary.total',
'totals.total_gross',
'totals.gross',
'summary.total_gross',
])),
'total_net' => $this->nullableFloat($this->readPath($payload, [
'total_net',
'price_netto',
'total_netto',
'totals.total_net',
'totals.net',
'summary.total_net',
])),
'buyer_email' => $this->nullableString($this->readPath($payload, [
'buyer.email',
'customer.email',
'client.email',
'user.email',
'user.mail',
'buyer.mail',
'customer.mail',
'email',
])),
'buyer_name' => $buyerName,
'buyer_phone' => $this->nullableString($this->readPath($payload, [
'buyer.phone',
'customer.phone',
'phone',
])),
'payment_method' => $this->nullableString($this->readPath($payload, [
'payment.method',
'payment_method',
])),
'payment_status' => $this->nullableString($this->readPath($payload, [
'payment.status',
'payment_status',
])),
'delivery_method' => $this->nullableString($this->readPath($payload, [
'delivery.method',
'shipping.method',
'delivery_method',
])),
'delivery_price' => $this->nullableFloat($this->readPath($payload, [
'delivery.price',
'shipping.price',
'delivery_price',
])),
'delivery_tracking_number' => $this->nullableString($this->readPath($payload, [
'delivery.tracking_number',
'shipping.tracking_number',
'tracking_number',
])),
'notes' => $this->nullableString($this->readPath($payload, [
'notes',
'note',
'comment',
])),
'external_created_at' => $externalCreatedAt,
'external_updated_at' => $externalUpdatedAt ?? $externalCreatedAt,
'fetched_at' => date('Y-m-d H:i:s'),
];
}
/**
* @param array<string, mixed> $payload
* @return array<int, array<string, mixed>>
*/
private function extractOrderItems(array $payload): array
{
$items = $this->readPath($payload, ['items']);
if (!is_array($items)) {
$items = $this->readPath($payload, ['order_items']);
}
if (!is_array($items)) {
$items = $this->readPath($payload, ['products']);
}
if (!is_array($items)) {
return [];
}
$result = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$result[] = [
'external_item_id' => $this->normalizeOrderId($this->readPath($item, [
'id',
'item_id',
'external_item_id',
'product_id',
])),
'name' => $this->nullableString($this->readPath($item, [
'name',
'title',
'product_name',
])),
'sku' => $this->nullableString($this->readPath($item, [
'sku',
'product_sku',
])),
'ean' => $this->nullableString($this->readPath($item, [
'ean',
'product_ean',
])),
'quantity' => $this->nullableFloat($this->readPath($item, [
'quantity',
'qty',
'count',
])),
'price_gross' => $this->nullableFloat($this->readPath($item, [
'price_gross',
'price_brutto',
'gross_price',
'price',
])),
'price_net' => $this->nullableFloat($this->readPath($item, [
'price_net',
'price_netto',
'net_price',
])),
'vat' => $this->nullableFloat($this->readPath($item, [
'vat',
'tax',
])),
'payload' => $item,
];
}
return $result;
}
private function resolveFromDate(?string $integrationStartDate, ?string $cursorDateTime): ?string
{
$cursorDate = null;
if ($cursorDateTime !== null) {
$cursorDate = substr($cursorDateTime, 0, 10);
}
if ($integrationStartDate === null) {
return $cursorDate;
}
if ($cursorDate === null) {
return $integrationStartDate;
}
return strcmp($integrationStartDate, $cursorDate) > 0
? $integrationStartDate
: $cursorDate;
}
private function readPath(array $data, array $paths): mixed
{
foreach ($paths as $path) {
$current = $data;
$segments = explode('.', $path);
$found = true;
foreach ($segments as $segment) {
if (!is_array($current) || !array_key_exists($segment, $current)) {
$found = false;
break;
}
$current = $current[$segment];
}
if ($found) {
return $current;
}
}
return null;
}
private function normalizeOrderId(mixed $value): string
{
$raw = trim((string) $value);
return $raw;
}
private function normalizeDateOnly(mixed $value): ?string
{
$text = trim((string) $value);
if ($text === '') {
return null;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $text) !== 1) {
return null;
}
return $text;
}
private function normalizeDateTime(mixed $value): ?string
{
$text = trim((string) $value);
if ($text === '') {
return null;
}
$timestamp = strtotime($text);
if ($timestamp === false) {
return null;
}
return date('Y-m-d H:i:s', $timestamp);
}
private function nullableString(mixed $value): ?string
{
$text = trim((string) $value);
return $text === '' ? null : $text;
}
private function nullableFloat(mixed $value): ?float
{
$text = trim((string) $value);
if ($text === '' || !is_numeric($text)) {
return null;
}
return (float) $text;
}
private function buildFullName(?string $firstName, ?string $lastName): ?string
{
$parts = array_filter([
trim((string) $firstName),
trim((string) $lastName),
], static fn (string $part): bool => $part !== '');
if ($parts === []) {
return null;
}
return implode(' ', $parts);
}
}

View File

@@ -0,0 +1,496 @@
<?php
declare(strict_types=1);
namespace App\Modules\Orders;
use App\Modules\Settings\IntegrationRepository;
use App\Modules\Settings\OrderStatusMappingRepository;
use App\Modules\Settings\ShopProClient;
use PDO;
use Throwable;
final class OrderStatusSyncService
{
private const DIRECTION_SHOPPRO_TO_ORDERPRO = 'shoppro_to_orderpro';
private const DIRECTION_ORDERPRO_TO_SHOPPRO = 'orderpro_to_shoppro';
public function __construct(
private readonly IntegrationRepository $integrations,
private readonly OrdersRepository $orders,
private readonly OrderStatusMappingRepository $mappings,
private readonly ShopProClient $shopProClient,
private readonly PDO $pdo
) {
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function sync(array $payload = []): array
{
$forcedIntegrationId = max(0, (int) ($payload['integration_id'] ?? 0));
$integrations = array_values(array_filter(
$this->integrations->listByType('shoppro'),
static function (array $integration) use ($forcedIntegrationId): bool {
$integrationId = (int) ($integration['id'] ?? 0);
if ($integrationId <= 0) {
return false;
}
if ($forcedIntegrationId > 0 && $integrationId !== $forcedIntegrationId) {
return false;
}
return ($integration['is_active'] ?? false) === true
&& ($integration['has_api_key'] ?? false) === true;
}
));
if ($integrations === []) {
return [
'ok' => true,
'message' => 'Brak aktywnych integracji do synchronizacji statusow.',
'checked_integrations' => 0,
'processed_orders' => 0,
'failed_integrations' => 0,
'errors' => [],
];
}
$processedOrders = 0;
$failedIntegrations = 0;
$errors = [];
foreach ($integrations as $integration) {
$integrationId = (int) ($integration['id'] ?? 0);
if ($integrationId <= 0) {
continue;
}
try {
$credentials = $this->integrations->findApiCredentials($integrationId);
if ($credentials === null || trim((string) ($credentials['api_key'] ?? '')) === '') {
throw new \RuntimeException('Brak poprawnych danych API.');
}
$direction = $this->normalizeDirection((string) ($integration['order_status_sync_direction'] ?? ''));
$result = $direction === self::DIRECTION_ORDERPRO_TO_SHOPPRO
? $this->syncOrderProToShopPro($integrationId, $credentials)
: $this->syncShopProToOrderPro($integrationId, $credentials, $integration);
$processedOrders += (int) ($result['processed_orders'] ?? 0);
} catch (Throwable $exception) {
$failedIntegrations++;
$this->touchState($integrationId, $this->normalizeDirection((string) ($integration['order_status_sync_direction'] ?? '')), $exception->getMessage());
if (count($errors) < 10) {
$errors[] = 'Integracja #' . $integrationId . ': ' . $exception->getMessage();
}
}
}
return [
'ok' => $failedIntegrations === 0,
'message' => $failedIntegrations === 0
? 'Synchronizacja statusow zamowien zakonczona.'
: 'Synchronizacja statusow zakonczona z bledami.',
'checked_integrations' => count($integrations),
'processed_orders' => $processedOrders,
'failed_integrations' => $failedIntegrations,
'errors' => $errors,
];
}
/**
* @param array<string, mixed> $credentials
* @param array<string, mixed> $integration
* @return array{processed_orders:int}
*/
private function syncShopProToOrderPro(int $integrationId, array $credentials, array $integration): array
{
$direction = self::DIRECTION_SHOPPRO_TO_ORDERPRO;
$state = $this->findState($integrationId, $direction);
$cursorAt = $this->normalizeDateTime($state['last_synced_at'] ?? null);
$cursorRef = trim((string) ($state['last_synced_order_ref'] ?? ''));
$fromDate = $this->resolveFromDate(
$this->normalizeDateOnly((string) ($integration['orders_fetch_start_date'] ?? '')),
$cursorAt
);
$response = $this->shopProClient->fetchOrders(
(string) ($credentials['base_url'] ?? ''),
(string) ($credentials['api_key'] ?? ''),
(int) ($credentials['timeout_seconds'] ?? 10),
1,
100,
$fromDate
);
if (($response['ok'] ?? false) !== true) {
$message = trim((string) ($response['message'] ?? 'Blad pobierania statusow z shopPRO.'));
$this->touchState($integrationId, $direction, $message);
throw new \RuntimeException($message);
}
$items = is_array($response['items'] ?? null) ? $response['items'] : [];
$candidates = $this->buildShopProCandidates($items, $cursorAt, $cursorRef);
if ($candidates === []) {
$this->touchState($integrationId, $direction, null);
return ['processed_orders' => 0];
}
$processed = 0;
foreach ($candidates as $candidate) {
$externalOrderId = (string) ($candidate['external_order_id'] ?? '');
$externalUpdatedAt = (string) ($candidate['external_updated_at'] ?? '');
$status = trim((string) ($candidate['status'] ?? ''));
if ($externalOrderId === '' || $externalUpdatedAt === '') {
continue;
}
$local = $this->orders->findByIntegrationExternalOrderId($integrationId, $externalOrderId);
if ($local !== null && $status !== '') {
$localStatus = trim((string) ($local['status'] ?? ''));
if (mb_strtolower($localStatus) !== mb_strtolower($status)) {
$this->orders->updateStatus((int) ($local['id'] ?? 0), $status, $externalUpdatedAt);
$processed++;
}
}
$this->advanceState($integrationId, $direction, $externalUpdatedAt, $externalOrderId);
}
return ['processed_orders' => $processed];
}
/**
* @param array<string, mixed> $credentials
* @return array{processed_orders:int}
*/
private function syncOrderProToShopPro(int $integrationId, array $credentials): array
{
$direction = self::DIRECTION_ORDERPRO_TO_SHOPPRO;
$state = $this->findState($integrationId, $direction);
$cursorAt = $this->normalizeDateTime($state['last_synced_at'] ?? null);
$cursorOrderId = max(0, (int) ($state['last_synced_order_ref'] ?? 0));
$rows = $this->orders->listForStatusPush($integrationId, $cursorAt, $cursorOrderId, 100);
if ($rows === []) {
$this->touchState($integrationId, $direction, null);
return ['processed_orders' => 0];
}
$mapping = $this->mappings->listOrderProToShopProMap($integrationId);
$processed = 0;
foreach ($rows as $row) {
$orderId = (int) ($row['id'] ?? 0);
$externalOrderId = trim((string) ($row['external_order_id'] ?? ''));
$orderProStatus = $this->normalizeCode((string) ($row['status'] ?? ''));
$updatedAt = (string) ($row['updated_at'] ?? '');
if ($orderId <= 0 || $updatedAt === '') {
continue;
}
if ($externalOrderId === '' || $orderProStatus === '' || !isset($mapping[$orderProStatus])) {
$this->advanceState($integrationId, $direction, $updatedAt, (string) $orderId);
continue;
}
$shopStatusCode = trim((string) $mapping[$orderProStatus]);
if ($shopStatusCode === '') {
$this->advanceState($integrationId, $direction, $updatedAt, (string) $orderId);
continue;
}
$response = $this->shopProClient->updateOrderStatus(
(string) ($credentials['base_url'] ?? ''),
(string) ($credentials['api_key'] ?? ''),
(int) ($credentials['timeout_seconds'] ?? 10),
$externalOrderId,
$shopStatusCode
);
if (($response['ok'] ?? false) !== true) {
$message = trim((string) ($response['message'] ?? 'Blad aktualizacji statusu zamowienia w shopPRO.'));
$this->touchState($integrationId, $direction, $message);
throw new \RuntimeException($message);
}
$this->advanceState($integrationId, $direction, $updatedAt, (string) $orderId);
$processed++;
}
return ['processed_orders' => $processed];
}
/**
* @param array<int, mixed> $items
* @return array<int, array{external_order_id:string,external_updated_at:string,status:string}>
*/
private function buildShopProCandidates(array $items, ?string $cursorAt, string $cursorRef): array
{
$result = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$externalOrderId = $this->normalizeOrderId($this->readPath($item, ['id', 'order_id', 'external_order_id']));
$externalUpdatedAt = $this->normalizeDateTime($this->readPath($item, [
'updated_at',
'date_updated',
'modified_at',
'date_modified',
'created_at',
'date_created',
]));
$status = trim((string) $this->readPath($item, ['status', 'order_status']));
if ($externalOrderId === '' || $externalUpdatedAt === null || $status === '') {
continue;
}
if (!$this->isAfterCursor($externalUpdatedAt, $externalOrderId, $cursorAt, $cursorRef)) {
continue;
}
$result[] = [
'external_order_id' => $externalOrderId,
'external_updated_at' => $externalUpdatedAt,
'status' => $status,
];
}
usort($result, function (array $a, array $b): int {
$dateCmp = strcmp((string) ($a['external_updated_at'] ?? ''), (string) ($b['external_updated_at'] ?? ''));
if ($dateCmp !== 0) {
return $dateCmp;
}
return $this->compareOrderRef(
(string) ($a['external_order_id'] ?? ''),
(string) ($b['external_order_id'] ?? '')
);
});
return $result;
}
private function isAfterCursor(string $itemAt, string $itemRef, ?string $cursorAt, string $cursorRef): bool
{
if ($cursorAt === null) {
return true;
}
$dateCmp = strcmp($itemAt, $cursorAt);
if ($dateCmp > 0) {
return true;
}
if ($dateCmp < 0) {
return false;
}
if ($cursorRef === '') {
return true;
}
return $this->compareOrderRef($itemRef, $cursorRef) > 0;
}
private function compareOrderRef(string $left, string $right): int
{
$leftRaw = trim($left);
$rightRaw = trim($right);
if (ctype_digit($leftRaw) && ctype_digit($rightRaw)) {
return (int) $leftRaw <=> (int) $rightRaw;
}
return strcmp($leftRaw, $rightRaw);
}
private function resolveFromDate(?string $integrationStartDate, ?string $cursorDateTime): ?string
{
$cursorDate = null;
if ($cursorDateTime !== null) {
$cursorDate = substr($cursorDateTime, 0, 10);
}
if ($integrationStartDate === null) {
return $cursorDate;
}
if ($cursorDate === null) {
return $integrationStartDate;
}
return strcmp($integrationStartDate, $cursorDate) > 0
? $integrationStartDate
: $cursorDate;
}
private function normalizeDirection(string $value): string
{
$normalized = trim(mb_strtolower($value));
if ($normalized === self::DIRECTION_ORDERPRO_TO_SHOPPRO) {
return self::DIRECTION_ORDERPRO_TO_SHOPPRO;
}
return self::DIRECTION_SHOPPRO_TO_ORDERPRO;
}
/**
* @return array<string, mixed>|null
*/
private function findState(int $integrationId, string $direction): ?array
{
$stmt = $this->pdo->prepare(
'SELECT integration_id, direction, last_synced_at, last_synced_order_ref, last_run_at, last_error
FROM integration_order_status_sync_state
WHERE integration_id = :integration_id
AND direction = :direction
LIMIT 1'
);
$stmt->execute([
'integration_id' => $integrationId,
'direction' => $direction,
]);
$row = $stmt->fetch();
return is_array($row) ? $row : null;
}
private function touchState(int $integrationId, string $direction, ?string $error): void
{
$now = date('Y-m-d H:i:s');
$stmt = $this->pdo->prepare(
'INSERT INTO integration_order_status_sync_state (
integration_id, direction, last_synced_at, last_synced_order_ref,
last_run_at, last_error, created_at, updated_at
) VALUES (
:integration_id, :direction, NULL, NULL,
:last_run_at, :last_error, :created_at, :updated_at
)
ON DUPLICATE KEY UPDATE
last_run_at = VALUES(last_run_at),
last_error = VALUES(last_error),
updated_at = VALUES(updated_at)'
);
$stmt->execute([
'integration_id' => $integrationId,
'direction' => $direction,
'last_run_at' => $now,
'last_error' => $this->nullableString($error),
'created_at' => $now,
'updated_at' => $now,
]);
}
private function advanceState(int $integrationId, string $direction, string $cursorAt, string $cursorRef): void
{
$now = date('Y-m-d H:i:s');
$stmt = $this->pdo->prepare(
'INSERT INTO integration_order_status_sync_state (
integration_id, direction, last_synced_at, last_synced_order_ref,
last_run_at, last_error, created_at, updated_at
) VALUES (
:integration_id, :direction, :last_synced_at, :last_synced_order_ref,
:last_run_at, NULL, :created_at, :updated_at
)
ON DUPLICATE KEY UPDATE
last_synced_at = VALUES(last_synced_at),
last_synced_order_ref = VALUES(last_synced_order_ref),
last_run_at = VALUES(last_run_at),
last_error = NULL,
updated_at = VALUES(updated_at)'
);
$stmt->execute([
'integration_id' => $integrationId,
'direction' => $direction,
'last_synced_at' => $cursorAt,
'last_synced_order_ref' => $cursorRef,
'last_run_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
}
private function readPath(array $data, array $paths): mixed
{
foreach ($paths as $path) {
$current = $data;
$segments = explode('.', (string) $path);
$found = true;
foreach ($segments as $segment) {
if (!is_array($current) || !array_key_exists($segment, $current)) {
$found = false;
break;
}
$current = $current[$segment];
}
if ($found) {
return $current;
}
}
return null;
}
private function normalizeOrderId(mixed $value): string
{
return trim((string) $value);
}
private function normalizeDateOnly(mixed $value): ?string
{
$text = trim((string) $value);
if ($text === '') {
return null;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $text) !== 1) {
return null;
}
return $text;
}
private function normalizeDateTime(mixed $value): ?string
{
$text = trim((string) $value);
if ($text === '') {
return null;
}
$timestamp = strtotime($text);
if ($timestamp === false) {
return null;
}
return date('Y-m-d H:i:s', $timestamp);
}
private function normalizeCode(string $value): string
{
return trim(mb_strtolower($value));
}
private function nullableString(mixed $value): ?string
{
$text = trim((string) $value);
return $text === '' ? null : $text;
}
}

View File

@@ -0,0 +1,963 @@
<?php
declare(strict_types=1);
namespace App\Modules\Orders;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use App\Modules\Settings\IntegrationRepository;
use App\Modules\Settings\ShopProClient;
use Throwable;
final class OrdersController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly OrdersRepository $orders,
private readonly IntegrationRepository $integrations,
private readonly ShopProClient $shopProClient
) {
}
public function index(Request $request): Response
{
$filters = [
'search' => trim((string) $request->input('search', '')),
'integration_id' => max(0, (int) $request->input('integration_id', 0)),
'status' => trim((string) $request->input('status', '')),
'date_from' => trim((string) $request->input('date_from', '')),
'date_to' => trim((string) $request->input('date_to', '')),
'sort' => (string) $request->input('sort', 'external_updated_at'),
'sort_dir' => (string) $request->input('sort_dir', 'DESC'),
'page' => max(1, (int) $request->input('page', 1)),
'per_page' => max(1, min(100, (int) $request->input('per_page', 20))),
];
$result = $this->orders->paginate($filters);
$totalPages = max(1, (int) ceil(((int) $result['total']) / max(1, (int) $result['per_page'])));
$result['items'] = $this->enrichRowsWithBuyerDetailsFromApi((array) ($result['items'] ?? []));
$statusNameMapByIntegration = $this->buildLiveStatusNameMap((array) ($result['items'] ?? []));
$integrationOptions = $this->integrations->listByType('shoppro');
$statusOptions = $this->buildStatusOptions((array) ($result['items'] ?? []), $statusNameMapByIntegration);
$html = $this->template->render('orders/index', [
'title' => $this->translator->get('orders.title'),
'activeMenu' => 'orders',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'marketplaceIntegrations' => $this->marketplaceIntegrations(),
'tableList' => [
'list_key' => 'orders',
'base_path' => '/orders',
'query' => $filters,
'filters' => [
[
'key' => 'search',
'label' => $this->translator->get('orders.filters.search'),
'type' => 'text',
'value' => $filters['search'],
],
[
'key' => 'integration_id',
'label' => $this->translator->get('orders.filters.integration'),
'type' => 'select',
'value' => (string) $filters['integration_id'],
'options' => $this->integrationFilterOptions($integrationOptions),
],
[
'key' => 'status',
'label' => $this->translator->get('orders.filters.status'),
'type' => 'select',
'value' => $filters['status'],
'options' => $statusOptions,
],
[
'key' => 'date_from',
'label' => $this->translator->get('orders.filters.date_from'),
'type' => 'date',
'value' => $filters['date_from'],
],
[
'key' => 'date_to',
'label' => $this->translator->get('orders.filters.date_to'),
'type' => 'date',
'value' => $filters['date_to'],
],
],
'columns' => [
['key' => 'id', 'label' => 'ID', 'sortable' => true, 'sort_key' => 'id'],
['key' => 'internal_order_number', 'label' => $this->translator->get('orders.fields.internal_order_number'), 'sortable' => true, 'sort_key' => 'internal_order_number'],
['key' => 'external_order_number', 'label' => $this->translator->get('orders.fields.external_order_number'), 'sortable' => true, 'sort_key' => 'external_order_number'],
['key' => 'status_badge', 'label' => $this->translator->get('orders.fields.status'), 'sortable' => true, 'sort_key' => 'status', 'raw' => true],
['key' => 'buyer_display', 'label' => $this->translator->get('orders.fields.buyer'), 'raw' => true],
['key' => 'total_gross', 'label' => $this->translator->get('orders.fields.total_gross'), 'sortable' => true, 'sort_key' => 'total_gross'],
['key' => 'currency', 'label' => $this->translator->get('orders.fields.currency'), 'sortable' => true, 'sort_key' => 'currency'],
['key' => 'external_created_at', 'label' => $this->translator->get('orders.fields.external_created_at'), 'sortable' => true, 'sort_key' => 'external_created_at'],
['key' => 'external_updated_at', 'label' => $this->translator->get('orders.fields.external_updated_at'), 'sortable' => true, 'sort_key' => 'external_updated_at'],
['key' => 'fetched_at', 'label' => $this->translator->get('orders.fields.fetched_at'), 'sortable' => true, 'sort_key' => 'fetched_at'],
],
'rows' => $this->tableRows((array) ($result['items'] ?? []), $statusNameMapByIntegration),
'pagination' => [
'page' => (int) ($result['page'] ?? 1),
'total_pages' => $totalPages,
'total' => (int) ($result['total'] ?? 0),
'per_page' => (int) ($result['per_page'] ?? 20),
],
'per_page_options' => [10, 20, 50, 100],
'empty_message' => $this->translator->get('orders.empty'),
'show_actions' => false,
],
'errorMessage' => (string) Flash::get('orders_error', ''),
'successMessage' => (string) Flash::get('orders_success', ''),
], 'layouts/app');
return Response::html($html);
}
/**
* @param array<int, array<string, mixed>> $rows
* @param array<int, array<string, string>> $statusNameMapByIntegration
* @return array<string, string>
*/
private function buildStatusOptions(array $rows, array $statusNameMapByIntegration): array
{
$options = ['' => $this->translator->get('orders.filters.any')];
foreach ($rows as $row) {
$status = trim((string) ($row['status'] ?? ''));
if ($status === '' || isset($options[$status])) {
continue;
}
$options[$status] = $this->resolveStatusLabel(
max(0, (int) ($row['integration_id'] ?? 0)),
$status,
trim((string) ($row['status_text'] ?? '')),
$statusNameMapByIntegration
);
}
return $options;
}
/**
* @param array<int, array<string, mixed>> $integrations
* @return array<string, string>
*/
private function integrationFilterOptions(array $integrations): array
{
$options = ['0' => $this->translator->get('orders.filters.any')];
foreach ($integrations as $integration) {
$id = max(0, (int) ($integration['id'] ?? 0));
if ($id <= 0) {
continue;
}
$options[(string) $id] = (string) ($integration['name'] ?? ('#' . $id));
}
return $options;
}
/**
* @param array<int, array<string, mixed>> $items
* @param array<int, array<string, string>> $statusNameMapByIntegration
* @return array<int, array<string, mixed>>
*/
private function tableRows(array $items, array $statusNameMapByIntegration): array
{
return array_map(function (array $row) use ($statusNameMapByIntegration): array {
$integrationId = max(0, (int) ($row['integration_id'] ?? 0));
$statusCode = trim((string) ($row['status'] ?? ''));
$statusText = trim((string) ($row['status_text'] ?? ''));
$payload = $this->decodePayload((string) ($row['payload_json'] ?? ''));
$buyerName = trim((string) ($row['buyer_name'] ?? ''));
if ($buyerName === '') {
$buyerName = $this->payloadString($payload, [
'buyer.name',
'customer.name',
'client.name',
'user.name',
'user.full_name',
]);
if ($buyerName === '') {
$buyerName = trim($this->payloadString($payload, [
'buyer.first_name',
'customer.first_name',
'client.first_name',
'user.firstname',
'user.first_name',
]) . ' ' . $this->payloadString($payload, [
'buyer.last_name',
'customer.last_name',
'client.last_name',
'user.lastname',
'user.last_name',
]));
}
if ($buyerName === '') {
$buyerName = $this->payloadStringFromBranches(
$payload,
['buyer', 'customer', 'client', 'user', 'billing', 'invoice', 'recipient', 'address'],
['name', 'full_name', 'fullname', 'client_name', 'company_name']
);
if ($buyerName === '') {
$buyerName = $this->heuristicBuyerName($payload);
}
}
}
$buyerLastName = $this->payloadString($payload, [
'buyer.last_name',
'customer.last_name',
'client.last_name',
'user.lastname',
'user.last_name',
'billing.last_name',
'invoice.last_name',
]);
if ($buyerLastName === '') {
$buyerLastName = $this->payloadStringFromBranches(
$payload,
['buyer', 'customer', 'client', 'user', 'billing', 'invoice', 'recipient', 'address'],
['last_name', 'lastname', 'surname', 'nazwisko']
);
}
$buyerName = $this->mergeBuyerNameAndLastName($buyerName, $buyerLastName);
$buyerEmail = trim((string) ($row['buyer_email'] ?? ''));
if ($buyerEmail === '') {
$buyerEmail = $this->payloadString($payload, [
'buyer.email',
'customer.email',
'client.email',
'user.email',
'user.mail',
'email',
]);
if ($buyerEmail === '') {
$buyerEmail = $this->payloadStringFromBranches(
$payload,
['buyer', 'customer', 'client', 'user', 'billing', 'invoice', 'recipient', 'address'],
['email', 'mail', 'e_mail']
);
if ($buyerEmail === '') {
$buyerEmail = $this->heuristicEmail($payload);
}
}
}
$currency = trim((string) ($row['currency'] ?? ''));
if ($currency === '') {
$currency = $this->payloadString($payload, [
'currency',
'currency_code',
'price_currency',
'order_currency',
'payment.currency',
'summary.currency',
]);
if ($currency === '') {
$currency = $this->payloadStringFromBranches(
$payload,
['summary', 'totals', 'payment', 'prices'],
['currency', 'currency_code', 'price_currency']
);
if ($currency === '') {
$currency = $this->heuristicCurrency($payload);
}
}
}
$totalGross = $row['total_gross'];
if ($totalGross === null) {
$totalGross = $this->payloadFloat($payload, [
'total_gross',
'total',
'sum',
'price_brutto',
'total_brutto',
'summary.total',
'summary.total_gross',
]);
if ($totalGross === null) {
$totalGross = $this->payloadFloatFromBranches(
$payload,
['summary', 'totals', 'payment', 'prices'],
['total_gross', 'total', 'sum', 'price_brutto', 'gross', 'amount']
);
if ($totalGross === null) {
$totalGross = $this->heuristicTotalGross($payload);
}
}
}
$externalCreatedAt = trim((string) ($row['external_created_at'] ?? ''));
if ($externalCreatedAt === '') {
$externalCreatedAt = $this->normalizeDateText($this->payloadString($payload, [
'created_at',
'date_created',
'date_add',
'add_date',
'order_date',
]));
if ($externalCreatedAt === '') {
$externalCreatedAt = $this->normalizeDateText($this->payloadStringFromBranches(
$payload,
['dates', 'order', 'summary'],
['created_at', 'date_created', 'date_add', 'add_date', 'order_date']
));
if ($externalCreatedAt === '') {
$externalCreatedAt = $this->heuristicCreatedAt($payload);
}
}
}
$buyer = $buyerName;
if ($buyerEmail !== '') {
$buyer = $buyer === '' ? $buyerEmail : ($buyer . ' (' . $buyerEmail . ')');
}
return [
'id' => (int) ($row['id'] ?? 0),
'internal_order_number' => (string) ($row['internal_order_number'] ?? ''),
'integration_name' => (string) ($row['integration_name'] ?? ''),
'external_order_id' => (string) ($row['external_order_id'] ?? ''),
'external_order_number' => (string) ($row['external_order_number'] ?? ''),
'status' => $this->resolveStatusLabel($integrationId, $statusCode, $statusText, $statusNameMapByIntegration),
'status_badge' => $this->statusBadgeHtml(
$this->resolveStatusLabel($integrationId, $statusCode, $statusText, $statusNameMapByIntegration)
),
'buyer' => $buyer,
'buyer_display' => $this->buyerHtml($buyerName, $buyerEmail),
'total_gross' => $totalGross === null
? ''
: number_format((float) $totalGross, 2, '.', ''),
'currency' => $currency,
'external_created_at' => $externalCreatedAt,
'external_updated_at' => (string) ($row['external_updated_at'] ?? ''),
'fetched_at' => (string) ($row['fetched_at'] ?? ''),
];
}, $items);
}
/**
* @param array<int, array<string, mixed>> $items
* @return array<int, array<string, string>>
*/
private function buildLiveStatusNameMap(array $items): array
{
$integrationIds = [];
foreach ($items as $row) {
$integrationId = max(0, (int) ($row['integration_id'] ?? 0));
if ($integrationId > 0) {
$integrationIds[$integrationId] = $integrationId;
}
}
if ($integrationIds === []) {
return [];
}
$result = [];
foreach (array_values($integrationIds) as $integrationId) {
try {
$credentials = $this->integrations->findApiCredentials($integrationId);
if ($credentials === null || trim((string) ($credentials['api_key'] ?? '')) === '') {
continue;
}
$statusesResult = $this->shopProClient->fetchOrderStatuses(
(string) ($credentials['base_url'] ?? ''),
(string) ($credentials['api_key'] ?? ''),
(int) ($credentials['timeout_seconds'] ?? 10)
);
if (($statusesResult['ok'] ?? false) !== true) {
continue;
}
$map = [];
$statuses = is_array($statusesResult['statuses'] ?? null) ? $statusesResult['statuses'] : [];
foreach ($statuses as $status) {
if (!is_array($status)) {
continue;
}
$code = trim((string) ($status['code'] ?? ''));
if ($code === '') {
continue;
}
$name = trim((string) ($status['name'] ?? $code));
if ($name === '') {
$name = $code;
}
$map[$this->normalizeStatusCode($code)] = $name;
}
if ($map !== []) {
$result[$integrationId] = $map;
}
} catch (Throwable) {
continue;
}
}
return $result;
}
/**
* @param array<int, array<string, string>> $statusNameMapByIntegration
*/
private function resolveStatusLabel(
int $integrationId,
string $statusCode,
string $statusText,
array $statusNameMapByIntegration
): string {
$rawCode = trim($statusCode);
$rawText = trim($statusText);
if ($rawText !== '' && !$this->isNumericStatusCode($rawText)) {
return $rawText;
}
if ($integrationId > 0 && $rawCode !== '' && isset($statusNameMapByIntegration[$integrationId])) {
$normalizedCode = $this->normalizeStatusCode($rawCode);
$label = trim((string) ($statusNameMapByIntegration[$integrationId][$normalizedCode] ?? ''));
if ($label !== '') {
return $label;
}
}
if ($rawText !== '') {
return $rawText;
}
return $rawCode;
}
private function normalizeStatusCode(string $value): string
{
return trim(mb_strtolower($value));
}
private function isNumericStatusCode(string $value): bool
{
return preg_match('/^\d+$/', trim($value)) === 1;
}
/**
* @return array<string, mixed>
*/
private function decodePayload(string $payloadJson): array
{
$raw = trim($payloadJson);
if ($raw === '') {
return [];
}
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
return $decoded;
}
if (is_string($decoded)) {
$second = json_decode($decoded, true);
if (is_array($second)) {
return $second;
}
}
return [];
}
/**
* @param array<string, mixed> $payload
* @param array<int, string> $paths
*/
private function payloadString(array $payload, array $paths): string
{
foreach ($paths as $path) {
$value = $this->payloadPath($payload, $path);
$text = trim((string) $value);
if ($text !== '') {
return $text;
}
}
return '';
}
/**
* @param array<string, mixed> $payload
* @param array<int, string> $branches
* @param array<int, string> $keys
*/
private function payloadStringFromBranches(array $payload, array $branches, array $keys): string
{
foreach ($branches as $branch) {
$node = $this->payloadPath($payload, $branch);
if (!is_array($node)) {
continue;
}
$found = $this->findFirstByKeysRecursive($node, $keys);
if ($found !== null) {
$text = trim((string) $found);
if ($text !== '') {
return $text;
}
}
}
$found = $this->findFirstByKeysRecursive($payload, $keys);
if ($found !== null) {
$text = trim((string) $found);
if ($text !== '') {
return $text;
}
}
return '';
}
/**
* @param array<string, mixed> $payload
* @param array<int, string> $paths
*/
private function payloadFloat(array $payload, array $paths): ?float
{
foreach ($paths as $path) {
$value = $this->payloadPath($payload, $path);
$text = trim((string) $value);
if ($text === '' || !is_numeric($text)) {
continue;
}
return (float) $text;
}
return null;
}
/**
* @param array<string, mixed> $payload
* @param array<int, string> $branches
* @param array<int, string> $keys
*/
private function payloadFloatFromBranches(array $payload, array $branches, array $keys): ?float
{
$value = $this->payloadStringFromBranches($payload, $branches, $keys);
if ($value === '' || !is_numeric($value)) {
return null;
}
return (float) $value;
}
/**
* @param array<string, mixed> $payload
*/
private function payloadPath(array $payload, string $path): mixed
{
$current = $payload;
foreach (explode('.', $path) as $segment) {
if (!is_array($current) || !array_key_exists($segment, $current)) {
return null;
}
$current = $current[$segment];
}
return $current;
}
/**
* @param array<string, mixed> $payload
* @param array<int, string> $keys
*/
private function findFirstByKeysRecursive(array $payload, array $keys): mixed
{
$normalizedKeys = array_map(
static fn (string $key): string => trim(mb_strtolower($key)),
$keys
);
foreach ($payload as $key => $value) {
$normalizedKey = trim(mb_strtolower((string) $key));
if (in_array($normalizedKey, $normalizedKeys, true) && !is_array($value)) {
return $value;
}
}
foreach ($payload as $value) {
if (!is_array($value)) {
continue;
}
$found = $this->findFirstByKeysRecursive($value, $keys);
if ($found !== null) {
return $found;
}
}
return null;
}
private function normalizeDateText(string $value): string
{
$text = trim($value);
if ($text === '') {
return '';
}
if (ctype_digit($text)) {
$timestamp = (int) $text;
if ($timestamp > 0) {
return date('Y-m-d H:i:s', $timestamp);
}
}
$timestamp = strtotime($text);
if ($timestamp === false) {
return $text;
}
return date('Y-m-d H:i:s', $timestamp);
}
/**
* @param array<string, mixed> $payload
*/
private function heuristicEmail(array $payload): string
{
foreach ($this->flattenPayload($payload) as $entry) {
$value = trim((string) ($entry['value'] ?? ''));
if ($value !== '' && filter_var($value, FILTER_VALIDATE_EMAIL) !== false) {
return $value;
}
}
return '';
}
/**
* @param array<string, mixed> $payload
*/
private function heuristicCurrency(array $payload): string
{
foreach ($this->flattenPayload($payload) as $entry) {
$key = trim(mb_strtolower((string) ($entry['key'] ?? '')));
$value = trim((string) ($entry['value'] ?? ''));
if ($value === '') {
continue;
}
if (str_contains($key, 'currency') || str_contains($key, 'walut')) {
return mb_strtoupper($value);
}
}
return '';
}
/**
* @param array<string, mixed> $payload
*/
private function heuristicTotalGross(array $payload): ?float
{
foreach ($this->flattenPayload($payload) as $entry) {
$key = trim(mb_strtolower((string) ($entry['key'] ?? '')));
$value = trim((string) ($entry['value'] ?? ''));
if ($value === '' || !is_numeric($value)) {
continue;
}
if (
str_contains($key, 'total') ||
str_contains($key, 'sum') ||
str_contains($key, 'gross') ||
str_contains($key, 'brutto') ||
str_contains($key, 'amount')
) {
return (float) $value;
}
}
return null;
}
/**
* @param array<string, mixed> $payload
*/
private function heuristicCreatedAt(array $payload): string
{
foreach ($this->flattenPayload($payload) as $entry) {
$key = trim(mb_strtolower((string) ($entry['key'] ?? '')));
if (
!str_contains($key, 'created') &&
!str_contains($key, 'date_add') &&
!str_contains($key, 'order_date') &&
!str_contains($key, 'add_date')
) {
continue;
}
$normalized = $this->normalizeDateText((string) ($entry['value'] ?? ''));
if ($normalized !== '') {
return $normalized;
}
}
return '';
}
/**
* @param array<string, mixed> $payload
*/
private function heuristicBuyerName(array $payload): string
{
foreach ($this->flattenPayload($payload) as $entry) {
$key = trim(mb_strtolower((string) ($entry['key'] ?? '')));
$value = trim((string) ($entry['value'] ?? ''));
if ($value === '' || mb_strlen($value) < 3 || str_contains($value, '@')) {
continue;
}
if (
str_contains($key, 'name') ||
str_contains($key, 'fullname') ||
str_contains($key, 'full_name') ||
str_contains($key, 'imie') ||
str_contains($key, 'nazw')
) {
return $value;
}
}
return '';
}
/**
* @param array<string, mixed> $payload
* @return array<int, array{key:string,value:string}>
*/
private function flattenPayload(array $payload, string $prefix = ''): array
{
$result = [];
foreach ($payload as $key => $value) {
$currentKey = $prefix === '' ? (string) $key : ($prefix . '.' . (string) $key);
if (is_array($value)) {
$result = array_merge($result, $this->flattenPayload($value, $currentKey));
continue;
}
$result[] = [
'key' => $currentKey,
'value' => (string) $value,
];
}
return $result;
}
private function statusBadgeHtml(string $statusLabel): string
{
$label = trim($statusLabel);
if ($label === '') {
return '<span class="order-status-badge is-empty">-</span>';
}
$class = 'is-default';
$normalized = mb_strtolower($label);
if (str_contains($normalized, 'anul') || str_contains($normalized, 'cancel') || str_contains($normalized, 'zwrot')) {
$class = 'is-danger';
} elseif (str_contains($normalized, 'wys') || str_contains($normalized, 'ship') || str_contains($normalized, 'dostar')) {
$class = 'is-success';
} elseif (str_contains($normalized, 'now') || str_contains($normalized, 'new')) {
$class = 'is-info';
} elseif (str_contains($normalized, 'realiz') || str_contains($normalized, 'progress')) {
$class = 'is-warn';
}
return '<span class="order-status-badge ' . $class . '">' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '</span>';
}
private function buyerHtml(string $buyerName, string $buyerEmail): string
{
$name = trim($buyerName);
$email = trim($buyerEmail);
if ($name === '' && $email === '') {
return '<span class="muted">-</span>';
}
$html = '<div class="order-buyer">';
if ($name !== '') {
$html .= '<div class="order-buyer__name">' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '</div>';
}
if ($email !== '') {
$html .= '<div class="order-buyer__email">' . htmlspecialchars($email, ENT_QUOTES, 'UTF-8') . '</div>';
}
$html .= '</div>';
return $html;
}
/**
* @param array<int, array<string, mixed>> $items
* @return array<int, array<string, mixed>>
*/
private function enrichRowsWithBuyerDetailsFromApi(array $items): array
{
if ($items === []) {
return $items;
}
$credentialsCache = [];
$maxLookups = 10;
$lookups = 0;
foreach ($items as $index => $row) {
if (!is_array($row)) {
continue;
}
if ($lookups >= $maxLookups) {
break;
}
$integrationId = max(0, (int) ($row['integration_id'] ?? 0));
$externalOrderId = trim((string) ($row['external_order_id'] ?? ''));
$buyerName = trim((string) ($row['buyer_name'] ?? ''));
if ($integrationId <= 0 || $externalOrderId === '' || $buyerName === '' && trim((string) ($row['buyer_email'] ?? '')) !== '') {
continue;
}
if (!$this->looksLikeMissingLastName($buyerName)) {
continue;
}
if (!array_key_exists($integrationId, $credentialsCache)) {
try {
$credentialsCache[$integrationId] = $this->integrations->findApiCredentials($integrationId);
} catch (Throwable) {
$credentialsCache[$integrationId] = null;
}
}
$credentials = $credentialsCache[$integrationId];
if (!is_array($credentials) || trim((string) ($credentials['api_key'] ?? '')) === '') {
continue;
}
try {
$details = $this->shopProClient->fetchOrderById(
(string) ($credentials['base_url'] ?? ''),
(string) ($credentials['api_key'] ?? ''),
(int) ($credentials['timeout_seconds'] ?? 10),
$externalOrderId
);
$lookups++;
} catch (Throwable) {
continue;
}
if (($details['ok'] ?? false) !== true || !is_array($details['order'] ?? null)) {
continue;
}
$orderPayload = $details['order'];
$firstName = trim((string) ($this->payloadPath($orderPayload, 'buyer.first_name')
?? $this->payloadPath($orderPayload, 'buyer.firstname')
?? $this->payloadPath($orderPayload, 'customer.first_name')
?? $this->payloadPath($orderPayload, 'customer.firstname')
?? $this->payloadPath($orderPayload, 'client.first_name')
?? $this->payloadPath($orderPayload, 'client.firstname')
?? $this->payloadPath($orderPayload, 'user.first_name')
?? $this->payloadPath($orderPayload, 'user.firstname')
?? ''));
$lastName = trim((string) ($this->payloadPath($orderPayload, 'buyer.last_name')
?? $this->payloadPath($orderPayload, 'buyer.lastname')
?? $this->payloadPath($orderPayload, 'customer.last_name')
?? $this->payloadPath($orderPayload, 'customer.lastname')
?? $this->payloadPath($orderPayload, 'client.last_name')
?? $this->payloadPath($orderPayload, 'client.lastname')
?? $this->payloadPath($orderPayload, 'user.last_name')
?? $this->payloadPath($orderPayload, 'user.lastname')
?? $this->payloadPath($orderPayload, 'surname')
?? ''));
$composed = $this->mergeBuyerNameAndLastName(
$firstName !== '' ? $firstName : $buyerName,
$lastName
);
if (trim($composed) !== '') {
$row['buyer_name'] = $composed;
}
$email = trim((string) ($this->payloadPath($orderPayload, 'buyer.email')
?? $this->payloadPath($orderPayload, 'customer.email')
?? $this->payloadPath($orderPayload, 'client.email')
?? $this->payloadPath($orderPayload, 'user.email')
?? $this->payloadPath($orderPayload, 'user.mail')
?? ''));
if ($email !== '') {
$row['buyer_email'] = $email;
}
$items[$index] = $row;
}
return $items;
}
private function looksLikeMissingLastName(string $buyerName): bool
{
$name = trim($buyerName);
if ($name === '') {
return true;
}
$parts = preg_split('/\s+/u', $name) ?: [];
return count(array_filter($parts, static fn (string $part): bool => $part !== '')) < 2;
}
private function mergeBuyerNameAndLastName(string $buyerName, string $buyerLastName): string
{
$name = trim($buyerName);
$lastName = trim($buyerLastName);
if ($name === '') {
return $lastName;
}
if ($lastName === '') {
return $name;
}
if (str_contains(mb_strtolower($name), mb_strtolower($lastName))) {
return $name;
}
return trim($name . ' ' . $lastName);
}
/**
* @return array<int, array<string, mixed>>
*/
private function marketplaceIntegrations(): array
{
return array_values(array_filter(
$this->integrations->listByType('shoppro'),
static fn (array $row): bool => (bool) ($row['is_active'] ?? false)
));
}
}

View File

@@ -0,0 +1,656 @@
<?php
declare(strict_types=1);
namespace App\Modules\Orders;
use PDO;
final class OrdersRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @param array<string, mixed> $filters
* @return array{items:array<int, array<string, mixed>>, total:int, page:int, per_page:int}
*/
public function paginate(array $filters): array
{
$page = max(1, (int) ($filters['page'] ?? 1));
$perPage = max(1, min(100, (int) ($filters['per_page'] ?? 20)));
$offset = ($page - 1) * $perPage;
[$whereSql, $params] = $this->buildFilters($filters);
$sort = $this->resolveSort((string) ($filters['sort'] ?? 'external_updated_at'));
$sortDir = strtoupper((string) ($filters['sort_dir'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
$countStmt = $this->pdo->prepare(
'SELECT COUNT(*)
FROM orders o
INNER JOIN integrations i ON i.id = o.integration_id
' . $whereSql
);
$countStmt->execute($params);
$total = (int) $countStmt->fetchColumn();
$listStmt = $this->pdo->prepare(
'SELECT o.id, o.internal_order_number, o.integration_id, o.external_order_id, o.external_order_number,
o.status, o.currency, o.total_gross, o.buyer_email, o.buyer_name,
o.external_created_at, o.external_updated_at, o.fetched_at, o.payload_json,
(
SELECT m.orderpro_status_code
FROM order_status_mappings m
WHERE m.integration_id = o.integration_id
AND LOWER(TRIM(m.shoppro_status_code)) = LOWER(TRIM(COALESCE(o.status, "")))
LIMIT 1
) AS orderpro_status_code,
(
SELECT m.shoppro_status_name
FROM order_status_mappings m
WHERE m.integration_id = o.integration_id
AND LOWER(TRIM(m.shoppro_status_code)) = LOWER(TRIM(COALESCE(o.status, "")))
LIMIT 1
) AS shoppro_status_name,
(
SELECT m.shoppro_status_name
FROM order_status_mappings m
WHERE m.integration_id = o.integration_id
AND LOWER(TRIM(m.orderpro_status_code)) = LOWER(TRIM(COALESCE(o.status, "")))
LIMIT 1
) AS shoppro_status_name_by_orderpro,
i.name AS integration_name
FROM orders o
INNER JOIN integrations i ON i.id = o.integration_id
' . $whereSql . '
ORDER BY ' . $sort . ' ' . $sortDir . '
LIMIT :limit OFFSET :offset'
);
foreach ($params as $key => $value) {
$listStmt->bindValue(':' . $key, $value);
}
$listStmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$listStmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$listStmt->execute();
$rows = $listStmt->fetchAll();
if (!is_array($rows)) {
$rows = [];
}
return [
'items' => array_map([$this, 'mapListRow'], $rows),
'total' => $total,
'page' => $page,
'per_page' => $perPage,
];
}
/**
* @param array<string, mixed> $order
* @param array<string, mixed>|null $payload
*/
public function upsertOrder(int $integrationId, array $order, ?array $payload = null): int
{
$stmt = $this->pdo->prepare(
'INSERT INTO orders (
integration_id, external_order_id, external_order_number, status, currency,
total_gross, total_net,
buyer_email, buyer_name, buyer_phone,
payment_method, payment_status,
delivery_method, delivery_price, delivery_tracking_number,
notes, external_created_at, external_updated_at,
payload_json, fetched_at, created_at, updated_at
) VALUES (
:integration_id, :external_order_id, :external_order_number, :status, :currency,
:total_gross, :total_net,
:buyer_email, :buyer_name, :buyer_phone,
:payment_method, :payment_status,
:delivery_method, :delivery_price, :delivery_tracking_number,
:notes, :external_created_at, :external_updated_at,
:payload_json, :fetched_at, :created_at, :updated_at
)
ON DUPLICATE KEY UPDATE
id = LAST_INSERT_ID(id),
external_order_number = VALUES(external_order_number),
status = VALUES(status),
currency = VALUES(currency),
total_gross = VALUES(total_gross),
total_net = VALUES(total_net),
buyer_email = VALUES(buyer_email),
buyer_name = VALUES(buyer_name),
buyer_phone = VALUES(buyer_phone),
payment_method = VALUES(payment_method),
payment_status = VALUES(payment_status),
delivery_method = VALUES(delivery_method),
delivery_price = VALUES(delivery_price),
delivery_tracking_number = VALUES(delivery_tracking_number),
notes = VALUES(notes),
external_created_at = VALUES(external_created_at),
external_updated_at = VALUES(external_updated_at),
payload_json = VALUES(payload_json),
fetched_at = VALUES(fetched_at),
updated_at = VALUES(updated_at)'
);
$now = date('Y-m-d H:i:s');
$stmt->execute([
'integration_id' => $integrationId,
'external_order_id' => (string) ($order['external_order_id'] ?? ''),
'external_order_number' => $this->nullableString($order['external_order_number'] ?? null),
'status' => $this->nullableString($order['status'] ?? null),
'currency' => $this->nullableString($order['currency'] ?? null),
'total_gross' => $this->nullableNumeric($order['total_gross'] ?? null, 2),
'total_net' => $this->nullableNumeric($order['total_net'] ?? null, 2),
'buyer_email' => $this->nullableString($order['buyer_email'] ?? null),
'buyer_name' => $this->nullableString($order['buyer_name'] ?? null),
'buyer_phone' => $this->nullableString($order['buyer_phone'] ?? null),
'payment_method' => $this->nullableString($order['payment_method'] ?? null),
'payment_status' => $this->nullableString($order['payment_status'] ?? null),
'delivery_method' => $this->nullableString($order['delivery_method'] ?? null),
'delivery_price' => $this->nullableNumeric($order['delivery_price'] ?? null, 2),
'delivery_tracking_number' => $this->nullableString($order['delivery_tracking_number'] ?? null),
'notes' => $this->nullableString($order['notes'] ?? null),
'external_created_at' => $this->nullableString($order['external_created_at'] ?? null),
'external_updated_at' => $this->nullableString($order['external_updated_at'] ?? null),
'payload_json' => $this->encodeJson($payload),
'fetched_at' => $this->nullableString($order['fetched_at'] ?? null) ?? $now,
'created_at' => $now,
'updated_at' => $now,
]);
$orderId = (int) $this->pdo->lastInsertId();
if ($orderId > 0) {
$this->ensureInternalOrderNumber($orderId);
}
return $orderId;
}
/**
* @param array<int, array<string, mixed>> $items
*/
public function replaceOrderItems(int $orderId, array $items): void
{
$deleteStmt = $this->pdo->prepare('DELETE FROM order_items WHERE order_id = :order_id');
$deleteStmt->execute(['order_id' => $orderId]);
if ($items === []) {
return;
}
$insertStmt = $this->pdo->prepare(
'INSERT INTO order_items (
order_id, external_item_id, name, sku, ean,
quantity, price_gross, price_net, vat,
payload_json, created_at, updated_at
) VALUES (
:order_id, :external_item_id, :name, :sku, :ean,
:quantity, :price_gross, :price_net, :vat,
:payload_json, :created_at, :updated_at
)'
);
$now = date('Y-m-d H:i:s');
foreach ($items as $item) {
$name = trim((string) ($item['name'] ?? ''));
if ($name === '') {
$name = 'Pozycja';
}
$insertStmt->execute([
'order_id' => $orderId,
'external_item_id' => $this->nullableString($item['external_item_id'] ?? null),
'name' => $name,
'sku' => $this->nullableString($item['sku'] ?? null),
'ean' => $this->nullableString($item['ean'] ?? null),
'quantity' => $this->nullableNumeric($item['quantity'] ?? null, 3) ?? 0,
'price_gross' => $this->nullableNumeric($item['price_gross'] ?? null, 2),
'price_net' => $this->nullableNumeric($item['price_net'] ?? null, 2),
'vat' => $this->nullableNumeric($item['vat'] ?? null, 2),
'payload_json' => $this->encodeJson(is_array($item['payload'] ?? null) ? $item['payload'] : null),
'created_at' => $now,
'updated_at' => $now,
]);
}
}
/**
* @return array<string, mixed>|null
*/
public function findSyncState(int $integrationId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT integration_id, last_synced_external_updated_at, last_synced_external_order_id, last_run_at, last_error
FROM integration_order_sync_state
WHERE integration_id = :integration_id
LIMIT 1'
);
$stmt->execute(['integration_id' => $integrationId]);
$row = $stmt->fetch();
if (!is_array($row)) {
return null;
}
return [
'integration_id' => (int) ($row['integration_id'] ?? 0),
'last_synced_external_updated_at' => $this->nullableString($row['last_synced_external_updated_at'] ?? null),
'last_synced_external_order_id' => $this->nullableString($row['last_synced_external_order_id'] ?? null),
'last_run_at' => $this->nullableString($row['last_run_at'] ?? null),
'last_error' => $this->nullableString($row['last_error'] ?? null),
];
}
public function touchSyncState(int $integrationId, ?string $error = null): void
{
$now = date('Y-m-d H:i:s');
$stmt = $this->pdo->prepare(
'INSERT INTO integration_order_sync_state (
integration_id, last_synced_external_updated_at, last_synced_external_order_id,
last_run_at, last_error, created_at, updated_at
) VALUES (
:integration_id, NULL, NULL,
:last_run_at, :last_error, :created_at, :updated_at
)
ON DUPLICATE KEY UPDATE
last_run_at = VALUES(last_run_at),
last_error = VALUES(last_error),
updated_at = VALUES(updated_at)'
);
$stmt->execute([
'integration_id' => $integrationId,
'last_run_at' => $now,
'last_error' => $this->nullableString($error),
'created_at' => $now,
'updated_at' => $now,
]);
}
public function advanceSyncState(int $integrationId, string $externalUpdatedAt, string $externalOrderId): void
{
$now = date('Y-m-d H:i:s');
$stmt = $this->pdo->prepare(
'INSERT INTO integration_order_sync_state (
integration_id, last_synced_external_updated_at, last_synced_external_order_id,
last_run_at, last_error, created_at, updated_at
) VALUES (
:integration_id, :last_synced_external_updated_at, :last_synced_external_order_id,
:last_run_at, NULL, :created_at, :updated_at
)
ON DUPLICATE KEY UPDATE
last_synced_external_updated_at = VALUES(last_synced_external_updated_at),
last_synced_external_order_id = VALUES(last_synced_external_order_id),
last_run_at = VALUES(last_run_at),
last_error = NULL,
updated_at = VALUES(updated_at)'
);
$stmt->execute([
'integration_id' => $integrationId,
'last_synced_external_updated_at' => $externalUpdatedAt,
'last_synced_external_order_id' => $externalOrderId,
'last_run_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
}
/**
* @return array<string, mixed>|null
*/
public function findByIntegrationExternalOrderId(int $integrationId, string $externalOrderId): ?array
{
$externalId = trim($externalOrderId);
if ($integrationId <= 0 || $externalId === '') {
return null;
}
$stmt = $this->pdo->prepare(
'SELECT id, integration_id, external_order_id, status, external_updated_at, updated_at
FROM orders
WHERE integration_id = :integration_id
AND external_order_id = :external_order_id
LIMIT 1'
);
$stmt->execute([
'integration_id' => $integrationId,
'external_order_id' => $externalId,
]);
$row = $stmt->fetch();
if (!is_array($row)) {
return null;
}
return [
'id' => (int) ($row['id'] ?? 0),
'integration_id' => (int) ($row['integration_id'] ?? 0),
'external_order_id' => (string) ($row['external_order_id'] ?? ''),
'status' => (string) ($row['status'] ?? ''),
'external_updated_at' => $this->nullableString($row['external_updated_at'] ?? null),
'updated_at' => $this->nullableString($row['updated_at'] ?? null),
];
}
public function updateStatus(int $orderId, string $status, ?string $externalUpdatedAt = null): void
{
if ($orderId <= 0) {
return;
}
$params = [
'id' => $orderId,
'status' => $this->nullableString($status),
'updated_at' => date('Y-m-d H:i:s'),
];
$sql = 'UPDATE orders SET status = :status, updated_at = :updated_at';
if ($externalUpdatedAt !== null && trim($externalUpdatedAt) !== '') {
$sql .= ', external_updated_at = :external_updated_at';
$params['external_updated_at'] = trim($externalUpdatedAt);
}
$sql .= ' WHERE id = :id';
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
}
/**
* @return array<int, array<string, mixed>>
*/
public function listForStatusPush(int $integrationId, ?string $cursorUpdatedAt, ?int $cursorOrderId, int $limit = 100): array
{
if ($integrationId <= 0) {
return [];
}
$safeLimit = max(1, min(500, $limit));
$params = [
'integration_id' => $integrationId,
];
$sql = 'SELECT id, integration_id, external_order_id, status, updated_at
FROM orders
WHERE integration_id = :integration_id
AND external_order_id IS NOT NULL
AND external_order_id <> ""';
$normalizedCursorUpdatedAt = $this->nullableString($cursorUpdatedAt);
if ($normalizedCursorUpdatedAt !== null) {
$params['cursor_updated_at'] = $normalizedCursorUpdatedAt;
$normalizedCursorOrderId = max(0, (int) ($cursorOrderId ?? 0));
$params['cursor_order_id'] = $normalizedCursorOrderId;
$sql .= ' AND (
updated_at > :cursor_updated_at
OR (updated_at = :cursor_updated_at AND id > :cursor_order_id)
)';
}
$sql .= ' ORDER BY updated_at ASC, id ASC
LIMIT :limit';
$stmt = $this->pdo->prepare($sql);
foreach ($params as $key => $value) {
if ($key === 'cursor_order_id' || $key === 'integration_id') {
$stmt->bindValue(':' . $key, (int) $value, PDO::PARAM_INT);
continue;
}
$stmt->bindValue(':' . $key, (string) $value);
}
$stmt->bindValue(':limit', $safeLimit, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map(
static fn (array $row): array => [
'id' => (int) ($row['id'] ?? 0),
'integration_id' => (int) ($row['integration_id'] ?? 0),
'external_order_id' => (string) ($row['external_order_id'] ?? ''),
'status' => (string) ($row['status'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
],
$rows
);
}
/**
* @param array<string, mixed> $filters
* @return array{0:string,1:array<string,mixed>}
*/
private function buildFilters(array $filters): array
{
$where = [];
$params = [];
$integrationId = max(0, (int) ($filters['integration_id'] ?? 0));
if ($integrationId > 0) {
$where[] = 'o.integration_id = :integration_id';
$params['integration_id'] = $integrationId;
}
$status = trim((string) ($filters['status'] ?? ''));
if ($status !== '') {
$where[] = 'o.status = :status';
$params['status'] = $status;
}
$search = trim((string) ($filters['search'] ?? ''));
if ($search !== '') {
$where[] = '(o.internal_order_number LIKE :search OR o.external_order_id LIKE :search OR o.external_order_number LIKE :search OR o.buyer_email LIKE :search OR o.buyer_name LIKE :search)';
$params['search'] = '%' . $search . '%';
}
$dateFrom = trim((string) ($filters['date_from'] ?? ''));
if ($dateFrom !== '') {
$where[] = 'DATE(o.external_updated_at) >= :date_from';
$params['date_from'] = $dateFrom;
}
$dateTo = trim((string) ($filters['date_to'] ?? ''));
if ($dateTo !== '') {
$where[] = 'DATE(o.external_updated_at) <= :date_to';
$params['date_to'] = $dateTo;
}
$whereSql = $where === [] ? '' : ('WHERE ' . implode(' AND ', $where));
return [$whereSql, $params];
}
private function resolveSort(string $sort): string
{
return match ($sort) {
'id' => 'o.id',
'internal_order_number' => 'o.internal_order_number',
'integration_name' => 'i.name',
'external_order_id' => 'o.external_order_id',
'external_order_number' => 'o.external_order_number',
'status' => 'o.status',
'buyer_name' => 'o.buyer_name',
'total_gross' => 'o.total_gross',
'currency' => 'o.currency',
'external_created_at' => 'o.external_created_at',
'fetched_at' => 'o.fetched_at',
default => 'o.external_updated_at',
};
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function mapListRow(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'internal_order_number' => (string) ($row['internal_order_number'] ?? ''),
'integration_id' => (int) ($row['integration_id'] ?? 0),
'integration_name' => (string) ($row['integration_name'] ?? ''),
'external_order_id' => (string) ($row['external_order_id'] ?? ''),
'external_order_number' => (string) ($row['external_order_number'] ?? ''),
'status' => (string) ($row['status'] ?? ''),
'status_text' => $this->resolveStatusText($row),
'currency' => (string) ($row['currency'] ?? ''),
'total_gross' => $row['total_gross'] === null ? null : (float) $row['total_gross'],
'buyer_email' => (string) ($row['buyer_email'] ?? ''),
'buyer_name' => (string) ($row['buyer_name'] ?? ''),
'external_created_at' => (string) ($row['external_created_at'] ?? ''),
'external_updated_at' => (string) ($row['external_updated_at'] ?? ''),
'fetched_at' => (string) ($row['fetched_at'] ?? ''),
'payload_json' => isset($row['payload_json']) ? (string) $row['payload_json'] : '',
];
}
private function nullableString(mixed $value): ?string
{
$text = trim((string) $value);
return $text === '' ? null : $text;
}
/**
* @param array<string, mixed> $row
*/
private function resolveStatusText(array $row): string
{
$shopName = trim((string) ($row['shoppro_status_name'] ?? ''));
if ($shopName !== '') {
return $shopName;
}
$shopNameByOrderPro = trim((string) ($row['shoppro_status_name_by_orderpro'] ?? ''));
if ($shopNameByOrderPro !== '') {
return $shopNameByOrderPro;
}
$payloadStatusName = $this->extractStatusNameFromPayload(
(string) ($row['payload_json'] ?? ''),
trim((string) ($row['status'] ?? ''))
);
if ($payloadStatusName !== '') {
return $payloadStatusName;
}
$orderProCode = trim((string) ($row['orderpro_status_code'] ?? ''));
if ($orderProCode !== '') {
return $orderProCode;
}
return trim((string) ($row['status'] ?? ''));
}
private function extractStatusNameFromPayload(string $payloadJson, string $statusCode = ''): string
{
$raw = trim($payloadJson);
if ($raw === '') {
return '';
}
$payload = json_decode($raw, true);
if (!is_array($payload)) {
return '';
}
$candidates = [
$this->payloadPath($payload, 'status_name'),
$this->payloadPath($payload, 'order_status_name'),
$this->payloadPath($payload, 'status.name'),
$this->payloadPath($payload, 'order_status.name'),
$this->payloadPath($payload, 'status.label'),
$this->payloadPath($payload, 'order_status.label'),
$this->payloadPath($payload, 'status_title'),
$this->payloadPath($payload, 'order_status_title'),
$this->payloadPath($payload, 'status_text'),
$this->payloadPath($payload, 'order_status_text'),
$this->payloadPath($payload, 'status.status_name'),
$this->payloadPath($payload, 'order_status.status_name'),
];
foreach ($candidates as $candidate) {
$text = trim((string) $candidate);
if ($text !== '') {
return $text;
}
}
$normalizedStatusCode = trim($statusCode);
if ($normalizedStatusCode !== '') {
$lookupCandidates = [
$this->payloadPath($payload, 'statuses.' . $normalizedStatusCode),
$this->payloadPath($payload, 'order_statuses.' . $normalizedStatusCode),
$this->payloadPath($payload, 'statuses_map.' . $normalizedStatusCode),
$this->payloadPath($payload, 'status_map.' . $normalizedStatusCode),
];
foreach ($lookupCandidates as $candidate) {
$text = trim((string) $candidate);
if ($text !== '') {
return $text;
}
}
}
return '';
}
private function payloadPath(array $payload, string $path): mixed
{
$current = $payload;
foreach (explode('.', $path) as $segment) {
if (!is_array($current) || !array_key_exists($segment, $current)) {
return null;
}
$current = $current[$segment];
}
return $current;
}
private function ensureInternalOrderNumber(int $orderId): void
{
if ($orderId <= 0) {
return;
}
$stmt = $this->pdo->prepare(
'UPDATE orders
SET internal_order_number = :internal_order_number
WHERE id = :id
AND (internal_order_number IS NULL OR internal_order_number = "")'
);
$stmt->execute([
'id' => $orderId,
'internal_order_number' => sprintf('OP%09d', $orderId),
]);
}
private function nullableNumeric(mixed $value, int $precision = 2): ?float
{
$text = trim((string) $value);
if ($text === '' || !is_numeric($text)) {
return null;
}
return round((float) $text, $precision);
}
/**
* @param array<string, mixed>|null $payload
*/
private function encodeJson(?array $payload): ?string
{
if ($payload === null) {
return null;
}
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
return null;
}
return $encoded;
}
}

View File

@@ -8,6 +8,10 @@ use RuntimeException;
final class IntegrationRepository
{
private ?bool $ordersFetchColumnsAvailable = null;
private ?bool $orderStatusSyncDirectionColumnAvailable = null;
private const ORDER_STATUS_SYNC_DIRECTION_DEFAULT = 'shoppro_to_orderpro';
public function __construct(
private readonly PDO $pdo,
private readonly string $secret
@@ -20,7 +24,9 @@ final class IntegrationRepository
public function listByType(string $type): array
{
$statement = $this->pdo->prepare(
'SELECT id, type, name, base_url, timeout_seconds, is_active,
'SELECT id, type, name, base_url, timeout_seconds, is_active'
. $this->ordersFetchSelectFragment()
. $this->orderStatusSyncDirectionSelectFragment() . ',
last_test_status, last_test_http_code, last_test_message, last_test_at,
created_at, updated_at,
CASE WHEN api_key_encrypted IS NULL OR api_key_encrypted = "" THEN 0 ELSE 1 END AS has_api_key
@@ -44,7 +50,9 @@ final class IntegrationRepository
public function findById(int $id): ?array
{
$statement = $this->pdo->prepare(
'SELECT id, type, name, base_url, timeout_seconds, is_active,
'SELECT id, type, name, base_url, timeout_seconds, is_active'
. $this->ordersFetchSelectFragment()
. $this->orderStatusSyncDirectionSelectFragment() . ',
last_test_status, last_test_http_code, last_test_message, last_test_at,
created_at, updated_at,
CASE WHEN api_key_encrypted IS NULL OR api_key_encrypted = "" THEN 0 ELSE 1 END AS has_api_key
@@ -68,7 +76,9 @@ final class IntegrationRepository
public function findApiCredentials(int $id): ?array
{
$statement = $this->pdo->prepare(
'SELECT id, name, base_url, timeout_seconds, api_key_encrypted
'SELECT id, name, base_url, timeout_seconds, api_key_encrypted'
. $this->ordersFetchSelectFragment()
. $this->orderStatusSyncDirectionSelectFragment() . '
FROM integrations
WHERE id = :id
LIMIT 1'
@@ -86,6 +96,9 @@ final class IntegrationRepository
'base_url' => (string) ($row['base_url'] ?? ''),
'timeout_seconds' => (int) ($row['timeout_seconds'] ?? 10),
'api_key' => $this->decryptApiKey((string) ($row['api_key_encrypted'] ?? '')),
'orders_fetch_enabled' => (int) ($row['orders_fetch_enabled'] ?? 0) === 1,
'orders_fetch_start_date' => $row['orders_fetch_start_date'] === null ? null : (string) $row['orders_fetch_start_date'],
'order_status_sync_direction' => $this->normalizeOrderStatusSyncDirection((string) ($row['order_status_sync_direction'] ?? self::ORDER_STATUS_SYNC_DIRECTION_DEFAULT)),
];
}
@@ -95,7 +108,9 @@ final class IntegrationRepository
public function findActiveApiCredentialsByType(string $type): ?array
{
$statement = $this->pdo->prepare(
'SELECT id, name, base_url, timeout_seconds, api_key_encrypted
'SELECT id, name, base_url, timeout_seconds, api_key_encrypted'
. $this->ordersFetchSelectFragment()
. $this->orderStatusSyncDirectionSelectFragment() . '
FROM integrations
WHERE type = :type AND is_active = 1
ORDER BY id DESC
@@ -114,6 +129,9 @@ final class IntegrationRepository
'base_url' => (string) ($row['base_url'] ?? ''),
'timeout_seconds' => (int) ($row['timeout_seconds'] ?? 10),
'api_key' => $this->decryptApiKey((string) ($row['api_key_encrypted'] ?? '')),
'orders_fetch_enabled' => (int) ($row['orders_fetch_enabled'] ?? 0) === 1,
'orders_fetch_start_date' => $row['orders_fetch_start_date'] === null ? null : (string) $row['orders_fetch_start_date'],
'order_status_sync_direction' => $this->normalizeOrderStatusSyncDirection((string) ($row['order_status_sync_direction'] ?? self::ORDER_STATUS_SYNC_DIRECTION_DEFAULT)),
];
}
@@ -123,25 +141,80 @@ final class IntegrationRepository
string $baseUrl,
int $timeoutSeconds,
bool $isActive,
string $apiKey
string $apiKey,
bool $ordersFetchEnabled = false,
?string $ordersFetchStartDate = null,
string $orderStatusSyncDirection = self::ORDER_STATUS_SYNC_DIRECTION_DEFAULT
): int {
$statement = $this->pdo->prepare(
'INSERT INTO integrations (
type, name, base_url, api_key_encrypted, timeout_seconds, is_active, created_at, updated_at
) VALUES (
:type, :name, :base_url, :api_key_encrypted, :timeout_seconds, :is_active, :created_at, :updated_at
)'
);
$statement->execute([
'type' => $type,
'name' => $name,
'base_url' => $baseUrl,
'api_key_encrypted' => $this->encryptApiKey($apiKey),
'timeout_seconds' => $timeoutSeconds,
'is_active' => $isActive ? 1 : 0,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
$normalizedSyncDirection = $this->normalizeOrderStatusSyncDirection($orderStatusSyncDirection);
if ($this->hasOrdersFetchColumns() && $this->hasOrderStatusSyncDirectionColumn()) {
$statement = $this->pdo->prepare(
'INSERT INTO integrations (
type, name, base_url, api_key_encrypted, timeout_seconds, is_active,
orders_fetch_enabled, orders_fetch_start_date, order_status_sync_direction,
created_at, updated_at
) VALUES (
:type, :name, :base_url, :api_key_encrypted, :timeout_seconds, :is_active,
:orders_fetch_enabled, :orders_fetch_start_date, :order_status_sync_direction,
:created_at, :updated_at
)'
);
$statement->execute([
'type' => $type,
'name' => $name,
'base_url' => $baseUrl,
'api_key_encrypted' => $this->encryptApiKey($apiKey),
'timeout_seconds' => $timeoutSeconds,
'is_active' => $isActive ? 1 : 0,
'orders_fetch_enabled' => $ordersFetchEnabled ? 1 : 0,
'orders_fetch_start_date' => $ordersFetchStartDate,
'order_status_sync_direction' => $normalizedSyncDirection,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
} elseif ($this->hasOrdersFetchColumns()) {
$statement = $this->pdo->prepare(
'INSERT INTO integrations (
type, name, base_url, api_key_encrypted, timeout_seconds, is_active,
orders_fetch_enabled, orders_fetch_start_date,
created_at, updated_at
) VALUES (
:type, :name, :base_url, :api_key_encrypted, :timeout_seconds, :is_active,
:orders_fetch_enabled, :orders_fetch_start_date,
:created_at, :updated_at
)'
);
$statement->execute([
'type' => $type,
'name' => $name,
'base_url' => $baseUrl,
'api_key_encrypted' => $this->encryptApiKey($apiKey),
'timeout_seconds' => $timeoutSeconds,
'is_active' => $isActive ? 1 : 0,
'orders_fetch_enabled' => $ordersFetchEnabled ? 1 : 0,
'orders_fetch_start_date' => $ordersFetchStartDate,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
} else {
$statement = $this->pdo->prepare(
'INSERT INTO integrations (
type, name, base_url, api_key_encrypted, timeout_seconds, is_active, created_at, updated_at
) VALUES (
:type, :name, :base_url, :api_key_encrypted, :timeout_seconds, :is_active, :created_at, :updated_at
)'
);
$statement->execute([
'type' => $type,
'name' => $name,
'base_url' => $baseUrl,
'api_key_encrypted' => $this->encryptApiKey($apiKey),
'timeout_seconds' => $timeoutSeconds,
'is_active' => $isActive ? 1 : 0,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
return (int) $this->pdo->lastInsertId();
}
@@ -152,14 +225,21 @@ final class IntegrationRepository
string $baseUrl,
int $timeoutSeconds,
bool $isActive,
?string $apiKey
?string $apiKey,
bool $ordersFetchEnabled = false,
?string $ordersFetchStartDate = null,
string $orderStatusSyncDirection = self::ORDER_STATUS_SYNC_DIRECTION_DEFAULT
): void {
$normalizedSyncDirection = $this->normalizeOrderStatusSyncDirection($orderStatusSyncDirection);
$params = [
'id' => $id,
'name' => $name,
'base_url' => $baseUrl,
'timeout_seconds' => $timeoutSeconds,
'is_active' => $isActive ? 1 : 0,
'orders_fetch_enabled' => $ordersFetchEnabled ? 1 : 0,
'orders_fetch_start_date' => $ordersFetchStartDate,
'order_status_sync_direction' => $normalizedSyncDirection,
'updated_at' => date('Y-m-d H:i:s'),
];
@@ -169,6 +249,29 @@ final class IntegrationRepository
timeout_seconds = :timeout_seconds,
is_active = :is_active,
updated_at = :updated_at';
if ($this->hasOrdersFetchColumns() && $this->hasOrderStatusSyncDirectionColumn()) {
$sql = 'UPDATE integrations SET
name = :name,
base_url = :base_url,
timeout_seconds = :timeout_seconds,
is_active = :is_active,
orders_fetch_enabled = :orders_fetch_enabled,
orders_fetch_start_date = :orders_fetch_start_date,
order_status_sync_direction = :order_status_sync_direction,
updated_at = :updated_at';
} elseif ($this->hasOrdersFetchColumns()) {
$sql = 'UPDATE integrations SET
name = :name,
base_url = :base_url,
timeout_seconds = :timeout_seconds,
is_active = :is_active,
orders_fetch_enabled = :orders_fetch_enabled,
orders_fetch_start_date = :orders_fetch_start_date,
updated_at = :updated_at';
unset($params['order_status_sync_direction']);
} else {
unset($params['orders_fetch_enabled'], $params['orders_fetch_start_date'], $params['order_status_sync_direction']);
}
if ($apiKey !== null && trim($apiKey) !== '') {
$sql .= ', api_key_encrypted = :api_key_encrypted';
@@ -416,6 +519,9 @@ final class IntegrationRepository
'base_url' => (string) ($row['base_url'] ?? ''),
'timeout_seconds' => (int) ($row['timeout_seconds'] ?? 10),
'is_active' => (int) ($row['is_active'] ?? 0) === 1,
'orders_fetch_enabled' => (int) ($row['orders_fetch_enabled'] ?? 0) === 1,
'orders_fetch_start_date' => $row['orders_fetch_start_date'] === null ? null : (string) $row['orders_fetch_start_date'],
'order_status_sync_direction' => $this->normalizeOrderStatusSyncDirection((string) ($row['order_status_sync_direction'] ?? self::ORDER_STATUS_SYNC_DIRECTION_DEFAULT)),
'last_test_status' => (string) ($row['last_test_status'] ?? ''),
'last_test_http_code' => $row['last_test_http_code'] === null ? null : (int) $row['last_test_http_code'],
'last_test_message' => (string) ($row['last_test_message'] ?? ''),
@@ -502,4 +608,72 @@ final class IntegrationRepository
return (int) $value;
}
private function ordersFetchSelectFragment(): string
{
if ($this->hasOrdersFetchColumns()) {
return ', orders_fetch_enabled, orders_fetch_start_date';
}
return ', 0 AS orders_fetch_enabled, NULL AS orders_fetch_start_date';
}
private function orderStatusSyncDirectionSelectFragment(): string
{
if ($this->hasOrderStatusSyncDirectionColumn()) {
return ', order_status_sync_direction';
}
return ", '" . self::ORDER_STATUS_SYNC_DIRECTION_DEFAULT . "' AS order_status_sync_direction";
}
private function hasOrdersFetchColumns(): bool
{
if ($this->ordersFetchColumnsAvailable !== null) {
return $this->ordersFetchColumnsAvailable;
}
try {
$enabledStmt = $this->pdo->query("SHOW COLUMNS FROM integrations LIKE 'orders_fetch_enabled'");
$startDateStmt = $this->pdo->query("SHOW COLUMNS FROM integrations LIKE 'orders_fetch_start_date'");
$this->ordersFetchColumnsAvailable =
$enabledStmt !== false
&& $enabledStmt->fetch() !== false
&& $startDateStmt !== false
&& $startDateStmt->fetch() !== false;
} catch (\Throwable) {
$this->ordersFetchColumnsAvailable = false;
}
return $this->ordersFetchColumnsAvailable;
}
private function hasOrderStatusSyncDirectionColumn(): bool
{
if ($this->orderStatusSyncDirectionColumnAvailable !== null) {
return $this->orderStatusSyncDirectionColumnAvailable;
}
try {
$stmt = $this->pdo->query("SHOW COLUMNS FROM integrations LIKE 'order_status_sync_direction'");
$this->orderStatusSyncDirectionColumnAvailable =
$stmt !== false
&& $stmt->fetch() !== false;
} catch (\Throwable) {
$this->orderStatusSyncDirectionColumnAvailable = false;
}
return $this->orderStatusSyncDirectionColumnAvailable;
}
private function normalizeOrderStatusSyncDirection(string $value): string
{
$normalized = trim(mb_strtolower($value));
if ($normalized === 'orderpro_to_shoppro') {
return 'orderpro_to_shoppro';
}
return self::ORDER_STATUS_SYNC_DIRECTION_DEFAULT;
}
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
final class OrderStatusMappingRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return array<string, array{orderpro_status_code:string, shoppro_status_name:string|null}>
*/
public function listByIntegration(int $integrationId): array
{
$stmt = $this->pdo->prepare(
'SELECT shoppro_status_code, shoppro_status_name, orderpro_status_code
FROM order_status_mappings
WHERE integration_id = :integration_id
ORDER BY shoppro_status_code ASC'
);
$stmt->execute(['integration_id' => $integrationId]);
$rows = $stmt->fetchAll();
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$code = trim((string) ($row['shoppro_status_code'] ?? ''));
if ($code === '') {
continue;
}
$result[$code] = [
'orderpro_status_code' => trim((string) ($row['orderpro_status_code'] ?? '')),
'shoppro_status_name' => isset($row['shoppro_status_name']) ? trim((string) $row['shoppro_status_name']) : null,
];
}
return $result;
}
/**
* @param array<int, array{shoppro_status_code:string,shoppro_status_name:string|null,orderpro_status_code:string}> $mappings
*/
public function replaceForIntegration(int $integrationId, array $mappings): void
{
$deleteStmt = $this->pdo->prepare('DELETE FROM order_status_mappings WHERE integration_id = :integration_id');
$deleteStmt->execute(['integration_id' => $integrationId]);
if ($mappings === []) {
return;
}
$insertStmt = $this->pdo->prepare(
'INSERT INTO order_status_mappings (
integration_id, shoppro_status_code, shoppro_status_name, orderpro_status_code, created_at, updated_at
) VALUES (
:integration_id, :shoppro_status_code, :shoppro_status_name, :orderpro_status_code, :created_at, :updated_at
)'
);
$now = date('Y-m-d H:i:s');
foreach ($mappings as $mapping) {
$shopCode = trim((string) ($mapping['shoppro_status_code'] ?? ''));
$orderCode = trim((string) ($mapping['orderpro_status_code'] ?? ''));
if ($shopCode === '' || $orderCode === '') {
continue;
}
$shopNameRaw = isset($mapping['shoppro_status_name']) ? trim((string) $mapping['shoppro_status_name']) : '';
$shopName = $shopNameRaw === '' ? null : $shopNameRaw;
$insertStmt->execute([
'integration_id' => $integrationId,
'shoppro_status_code' => $shopCode,
'shoppro_status_name' => $shopName,
'orderpro_status_code' => $orderCode,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
/**
* @return array<string, string>
*/
public function listOrderProToShopProMap(int $integrationId): array
{
$rows = $this->listByIntegration($integrationId);
if ($rows === []) {
return [];
}
$result = [];
foreach ($rows as $shopCode => $mapping) {
$orderProCode = trim((string) ($mapping['orderpro_status_code'] ?? ''));
$normalizedOrderProCode = $this->normalizeCode($orderProCode);
$normalizedShopCode = $this->normalizeCode((string) $shopCode);
if ($normalizedOrderProCode === '' || $normalizedShopCode === '') {
continue;
}
if (!isset($result[$normalizedOrderProCode])) {
$result[$normalizedOrderProCode] = $normalizedShopCode;
}
}
return $result;
}
private function normalizeCode(string $value): string
{
return trim(mb_strtolower($value));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -54,6 +54,135 @@ final class ShopProClient
];
}
/**
* @return array{ok:bool,http_code:int|null,message:string,items:array<int,array<string,mixed>>,total:int,page:int,per_page:int}
*/
public function fetchOrders(
string $baseUrl,
string $apiKey,
int $timeoutSeconds,
int $page = 1,
int $perPage = 100,
?string $fromDate = null
): array {
$normalizedBaseUrl = rtrim(trim($baseUrl), '/');
$queryParams = [
'endpoint' => 'orders',
'action' => 'list',
'page' => max(1, $page),
'per_page' => max(1, min(100, $perPage)),
'sort' => 'updated_at',
'sort_dir' => 'ASC',
];
$normalizedFromDate = trim((string) $fromDate);
if ($normalizedFromDate !== '') {
$queryParams['date_from'] = $normalizedFromDate;
$queryParams['updated_from'] = $normalizedFromDate;
}
$endpointUrl = $normalizedBaseUrl . '/api.php?' . http_build_query($queryParams);
$response = $this->requestJson($endpointUrl, $apiKey, $timeoutSeconds);
if (($response['ok'] ?? false) !== true) {
return [
'ok' => false,
'http_code' => $response['http_code'] ?? null,
'message' => (string) ($response['message'] ?? 'Nie mozna pobrac listy zamowien z shopPRO.'),
'items' => [],
'total' => 0,
'page' => max(1, $page),
'per_page' => max(1, min(100, $perPage)),
];
}
$data = is_array($response['data'] ?? null) ? $response['data'] : [];
$items = [];
if (isset($data['items']) && is_array($data['items'])) {
$items = $data['items'];
} elseif (isset($data['orders']) && is_array($data['orders'])) {
$items = $data['orders'];
} elseif ($data !== [] && array_keys($data) === range(0, count($data) - 1)) {
$items = $data;
}
return [
'ok' => true,
'http_code' => $response['http_code'] ?? null,
'message' => '',
'items' => array_values(array_filter($items, static fn (mixed $item): bool => is_array($item))),
'total' => (int) ($data['total'] ?? count($items)),
'page' => (int) ($data['page'] ?? max(1, $page)),
'per_page' => (int) ($data['per_page'] ?? max(1, min(100, $perPage))),
];
}
/**
* @return array{ok:bool,http_code:int|null,message:string,order:array<string,mixed>|null}
*/
public function fetchOrderById(string $baseUrl, string $apiKey, int $timeoutSeconds, string $orderId): array
{
$id = trim($orderId);
if ($id === '') {
return [
'ok' => false,
'http_code' => null,
'message' => 'Niepoprawne ID zamowienia.',
'order' => null,
];
}
$normalizedBaseUrl = rtrim(trim($baseUrl), '/');
$attempts = [
$normalizedBaseUrl . '/api.php?' . http_build_query([
'endpoint' => 'orders',
'action' => 'get',
'id' => $id,
]),
$normalizedBaseUrl . '/api.php?' . http_build_query([
'endpoint' => 'orders',
'action' => 'details',
'id' => $id,
]),
];
$lastMessage = 'Nie mozna pobrac szczegolow zamowienia z shopPRO.';
$lastHttpCode = null;
foreach ($attempts as $endpointUrl) {
$response = $this->requestJson($endpointUrl, $apiKey, $timeoutSeconds);
if (($response['ok'] ?? false) !== true) {
$lastMessage = trim((string) ($response['message'] ?? $lastMessage));
$lastHttpCode = isset($response['http_code']) ? (int) $response['http_code'] : null;
continue;
}
$data = $response['data'] ?? null;
if (is_array($data)) {
if (isset($data['order']) && is_array($data['order'])) {
return [
'ok' => true,
'http_code' => $response['http_code'] ?? null,
'message' => '',
'order' => $data['order'],
];
}
return [
'ok' => true,
'http_code' => $response['http_code'] ?? null,
'message' => '',
'order' => $data,
];
}
}
return [
'ok' => false,
'http_code' => $lastHttpCode,
'message' => $lastMessage,
'order' => null,
];
}
/**
* @return array{ok:bool,http_code:int|null,message:string,product:array<string,mixed>|null}
*/
@@ -528,6 +657,128 @@ final class ShopProClient
];
}
/**
* @return array{ok:bool,http_code:int|null,message:string,statuses:array<int,array{code:string,name:string}>}
*/
public function fetchOrderStatuses(string $baseUrl, string $apiKey, int $timeoutSeconds): array
{
$normalizedBaseUrl = rtrim(trim($baseUrl), '/');
$endpointUrl = $normalizedBaseUrl . '/api.php?endpoint=dictionaries&action=statuses';
$response = $this->requestJson($endpointUrl, $apiKey, $timeoutSeconds);
if (($response['ok'] ?? false) !== true) {
return [
'ok' => false,
'http_code' => $response['http_code'] ?? null,
'message' => (string) ($response['message'] ?? 'Nie mozna pobrac statusow zamowien z shopPRO.'),
'statuses' => [],
];
}
$data = is_array($response['data'] ?? null) ? $response['data'] : [];
$rawStatuses = [];
if (isset($data['statuses']) && is_array($data['statuses'])) {
$rawStatuses = $data['statuses'];
} elseif (isset($data['order_statuses']) && is_array($data['order_statuses'])) {
$rawStatuses = $data['order_statuses'];
} elseif ($data !== [] && array_keys($data) === range(0, count($data) - 1)) {
$rawStatuses = $data;
}
$statuses = $this->normalizeStatusesPayload($rawStatuses);
return [
'ok' => true,
'http_code' => $response['http_code'] ?? null,
'message' => '',
'statuses' => $statuses,
];
}
/**
* @return array{ok:bool,http_code:int|null,message:string,endpoint_url:string,method:string}
*/
public function updateOrderStatus(
string $baseUrl,
string $apiKey,
int $timeoutSeconds,
string $externalOrderId,
string $shopProStatusCode
): array {
$orderId = trim($externalOrderId);
$statusCode = trim($shopProStatusCode);
if ($orderId === '' || $statusCode === '') {
return [
'ok' => false,
'http_code' => null,
'message' => 'Brak ID zamowienia lub kodu statusu do synchronizacji.',
'endpoint_url' => '',
'method' => '',
];
}
$normalizedBaseUrl = rtrim(trim($baseUrl), '/');
$attempts = [
[
'url' => $normalizedBaseUrl . '/api.php?endpoint=orders&action=update_status',
'method' => 'POST',
'payload' => ['id' => $orderId, 'status' => $statusCode],
],
[
'url' => $normalizedBaseUrl . '/api.php?endpoint=orders&action=update_status',
'method' => 'PUT',
'payload' => ['id' => $orderId, 'status' => $statusCode],
],
[
'url' => $normalizedBaseUrl . '/api.php?endpoint=orders&action=set_status',
'method' => 'POST',
'payload' => ['id' => $orderId, 'status' => $statusCode],
],
[
'url' => $normalizedBaseUrl . '/api.php?endpoint=orders&action=update',
'method' => 'PUT',
'payload' => ['id' => $orderId, 'status' => $statusCode],
],
];
$lastFailure = [
'ok' => false,
'http_code' => null,
'message' => 'Nie udalo sie zaktualizowac statusu zamowienia po stronie shopPRO.',
'endpoint_url' => '',
'method' => '',
];
foreach ($attempts as $attempt) {
$response = $this->requestJson(
(string) ($attempt['url'] ?? ''),
$apiKey,
$timeoutSeconds,
(string) ($attempt['method'] ?? 'POST'),
is_array($attempt['payload'] ?? null) ? $attempt['payload'] : null
);
if (($response['ok'] ?? false) === true) {
return [
'ok' => true,
'http_code' => $response['http_code'] ?? null,
'message' => '',
'endpoint_url' => (string) ($attempt['url'] ?? ''),
'method' => (string) ($attempt['method'] ?? ''),
];
}
$lastFailure = [
'ok' => false,
'http_code' => $response['http_code'] ?? null,
'message' => trim((string) ($response['message'] ?? 'Nieznany blad synchronizacji statusu.')),
'endpoint_url' => (string) ($attempt['url'] ?? ''),
'method' => (string) ($attempt['method'] ?? ''),
];
}
return $lastFailure;
}
/**
* @return array{ok:bool,http_code:int|null,message:string,producer_id:int,created:bool}
*/
@@ -851,4 +1102,37 @@ final class ShopProClient
return $body;
}
/**
* @param array<int|string, mixed> $rawStatuses
* @return array<int, array{code:string,name:string}>
*/
private function normalizeStatusesPayload(array $rawStatuses): array
{
$normalized = [];
foreach ($rawStatuses as $key => $item) {
if (is_array($item)) {
$code = trim((string) ($item['code'] ?? $item['id'] ?? $item['status'] ?? $key));
$name = trim((string) ($item['name'] ?? $item['label'] ?? $item['title'] ?? $code));
} else {
$code = trim((string) (is_string($key) ? $key : $item));
$name = trim((string) $item);
if ($name === '') {
$name = $code;
}
}
if ($code === '') {
continue;
}
$normalized[$code] = [
'code' => $code,
'name' => $name !== '' ? $name : $code,
];
}
return array_values($normalized);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Cron;
use App\Modules\Cron\CronJobType;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(CronJobType::class)]
final class CronJobTypeTest extends TestCase
{
public function testPriorityForOrderStatusSyncJob(): void
{
self::assertSame(95, CronJobType::priorityFor(CronJobType::SHOPPRO_ORDER_STATUS_SYNC));
}
public function testMaxAttemptsForOrderStatusSyncJob(): void
{
self::assertSame(3, CronJobType::maxAttemptsFor(CronJobType::SHOPPRO_ORDER_STATUS_SYNC));
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Settings;
use App\Modules\Settings\OrderStatusMappingRepository;
use PDO;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(OrderStatusMappingRepository::class)]
final class OrderStatusMappingRepositoryTest extends TestCase
{
private PDO $pdo;
private OrderStatusMappingRepository $repository;
protected function setUp(): void
{
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->exec(
'CREATE TABLE order_status_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
integration_id INTEGER NOT NULL,
shoppro_status_code VARCHAR(64) NOT NULL,
shoppro_status_name VARCHAR(128) NULL,
orderpro_status_code VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
)'
);
$this->repository = new OrderStatusMappingRepository($this->pdo);
}
public function testReplaceAndReadMappingsForIntegration(): void
{
$this->repository->replaceForIntegration(10, [
[
'shoppro_status_code' => 'new',
'shoppro_status_name' => 'Nowe',
'orderpro_status_code' => 'new',
],
[
'shoppro_status_code' => 'paid',
'shoppro_status_name' => 'Oplacone',
'orderpro_status_code' => 'completed',
],
]);
$rows = $this->repository->listByIntegration(10);
self::assertArrayHasKey('new', $rows);
self::assertSame('Nowe', $rows['new']['shoppro_status_name']);
self::assertSame('new', $rows['new']['orderpro_status_code']);
self::assertSame('completed', $rows['paid']['orderpro_status_code']);
}
public function testListOrderProToShopProMapNormalizesCodes(): void
{
$this->repository->replaceForIntegration(11, [
[
'shoppro_status_code' => 'Paid',
'shoppro_status_name' => 'Oplacone',
'orderpro_status_code' => 'Completed',
],
]);
$map = $this->repository->listOrderProToShopProMap(11);
self::assertSame('paid', $map['completed']);
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Settings;
use App\Modules\Settings\ShopProClient;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;
#[CoversClass(ShopProClient::class)]
final class ShopProClientTest extends TestCase
{
public function testNormalizeStatusesPayloadSupportsMultipleShapes(): void
{
$client = new ShopProClient();
$method = new ReflectionMethod($client, 'normalizeStatusesPayload');
$method->setAccessible(true);
$normalized = $method->invoke($client, [
['id' => 8, 'name' => 'Wyslane'],
['code' => 'completed', 'label' => 'Zakonczone'],
'cancelled' => 'Anulowane',
]);
self::assertSame([
['code' => '8', 'name' => 'Wyslane'],
['code' => 'completed', 'name' => 'Zakonczone'],
['code' => 'cancelled', 'name' => 'Anulowane'],
], $normalized);
}
}

View File

@@ -1,90 +1,5 @@
<?php
declare(strict_types=1);
use App\Core\Database\ConnectionFactory;
use App\Core\Support\Env;
use App\Modules\Cron\CronJobProcessor;
use App\Modules\Cron\CronJobRepository;
use App\Modules\Cron\CronJobType;
use App\Modules\Cron\ProductLinksHealthCheckHandler;
use App\Modules\Cron\ShopProOfferTitlesRefreshHandler;
use App\Modules\ProductLinks\ChannelOffersRepository;
use App\Modules\ProductLinks\OfferImportService;
use App\Modules\ProductLinks\ProductLinksRepository;
use App\Modules\Settings\IntegrationRepository;
use App\Modules\Settings\ShopProClient;
$basePath = dirname(__DIR__);
$vendorAutoload = $basePath . '/vendor/autoload.php';
if (is_file($vendorAutoload)) {
require $vendorAutoload;
} else {
spl_autoload_register(static function (string $class) use ($basePath): void {
$prefix = 'App\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relative = substr($class, strlen($prefix));
$file = $basePath . '/src/' . str_replace('\\', '/', $relative) . '.php';
if (is_file($file)) {
require $file;
}
});
}
Env::load($basePath . '/.env');
/** @var array<string, mixed> $dbConfig */
$dbConfig = require $basePath . '/config/database.php';
/** @var array<string, mixed> $appConfig */
$appConfig = require $basePath . '/config/app.php';
$limit = 20;
foreach ($argv as $argument) {
if (!str_starts_with((string) $argument, '--limit=')) {
continue;
}
$limitValue = (int) substr((string) $argument, strlen('--limit='));
if ($limitValue > 0) {
$limit = min(200, $limitValue);
}
}
try {
$pdo = ConnectionFactory::make($dbConfig);
$cronJobs = new CronJobRepository($pdo);
$processor = new CronJobProcessor($cronJobs);
$integrationRepository = new IntegrationRepository(
$pdo,
(string) (($appConfig['integrations']['secret'] ?? '') ?: '')
);
$offersRepository = new ChannelOffersRepository($pdo);
$linksRepository = new ProductLinksRepository($pdo);
$shopProClient = new ShopProClient();
$offerImportService = new OfferImportService($shopProClient, $offersRepository, $pdo);
$linksHealthCheckHandler = new ProductLinksHealthCheckHandler(
$integrationRepository,
$offerImportService,
$linksRepository,
$offersRepository
);
$offerTitlesRefreshHandler = new ShopProOfferTitlesRefreshHandler(
$integrationRepository,
$offerImportService
);
$processor->registerHandler(CronJobType::PRODUCT_LINKS_HEALTH_CHECK, $linksHealthCheckHandler);
$processor->registerHandler(CronJobType::SHOPPRO_OFFER_TITLES_REFRESH, $offerTitlesRefreshHandler);
$result = $processor->run($limit);
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL;
} catch (\Throwable $exception) {
fwrite(STDERR, '[error] ' . $exception->getMessage() . PHP_EOL);
exit(1);
}
fwrite(STDERR, "Cron module has been archived in users-only reset.\n");
exit(1);

View File

@@ -0,0 +1,724 @@
<?php
declare(strict_types=1);
use App\Core\Database\ConnectionFactory;
use App\Core\Support\Env;
$basePath = dirname(__DIR__);
$vendorAutoload = $basePath . '/vendor/autoload.php';
if (is_file($vendorAutoload)) {
require $vendorAutoload;
} else {
spl_autoload_register(static function (string $class) use ($basePath): void {
$prefix = 'App\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relative = substr($class, strlen($prefix));
$file = $basePath . '/src/' . str_replace('\\', '/', $relative) . '.php';
if (is_file($file)) {
require $file;
}
});
}
Env::load($basePath . '/.env');
/** @var array<string, mixed> $dbConfig */
$dbConfig = require $basePath . '/config/database.php';
$args = $argv;
array_shift($args);
$useRemote = in_array('--use-remote', $args, true);
$append = in_array('--append', $args, true);
$count = 30;
$profile = 'default';
foreach ($args as $arg) {
if (str_starts_with($arg, '--count=')) {
$count = max(1, min(500, (int) substr($arg, 8)));
}
if (str_starts_with($arg, '--profile=')) {
$parsedProfile = strtolower(trim((string) substr($arg, 10)));
if (in_array($parsedProfile, ['default', 'realistic'], true)) {
$profile = $parsedProfile;
}
}
}
if ($useRemote) {
$remoteHost = (string) Env::get('DB_HOST_REMOTE', '');
if ($remoteHost !== '') {
$dbConfig['host'] = $remoteHost;
}
}
$pdo = ConnectionFactory::make($dbConfig);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo 'Deploy + seed orders schema' . PHP_EOL;
echo '[db-host] ' . (string) ($dbConfig['host'] ?? '') . PHP_EOL;
echo '[count] ' . $count . PHP_EOL;
echo '[profile] ' . $profile . PHP_EOL;
echo '[mode] ' . ($append ? 'append' : 'reset+seed') . PHP_EOL;
try {
deploySchema($pdo, $basePath . '/database/drafts/20260302_orders_schema_v1.sql');
seedData($pdo, $count, $append, $profile);
printSummary($pdo);
echo 'Done.' . PHP_EOL;
} catch (Throwable $exception) {
fwrite(STDERR, '[error] ' . $exception->getMessage() . PHP_EOL);
exit(1);
}
function deploySchema(PDO $pdo, string $sqlFile): void
{
if (!is_file($sqlFile)) {
throw new RuntimeException('SQL draft file not found: ' . $sqlFile);
}
$rawSql = (string) file_get_contents($sqlFile);
$withoutLineComments = preg_replace('/^\s*--.*$/m', '', $rawSql) ?? $rawSql;
$statements = preg_split('/;\s*[\r\n]+/', $withoutLineComments) ?: [];
foreach ($statements as $statement) {
$sql = trim($statement);
if ($sql === '') {
continue;
}
$pdo->exec($sql);
}
echo '[schema] applied' . PHP_EOL;
}
function seedData(PDO $pdo, int $count, bool $append, string $profile): void
{
if (!$append) {
$pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
foreach ([
'order_tag_links',
'order_status_history',
'order_notes',
'order_documents',
'order_shipments',
'order_payments',
'order_items',
'order_addresses',
'orders',
'order_tags_dict',
'integration_order_sync_state',
] as $table) {
$pdo->exec('TRUNCATE TABLE ' . $table);
}
$pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
}
$now = new DateTimeImmutable('now');
$statuses = loadStatusesForSeed($pdo);
$paymentTypes = ['transfer', 'card', 'cod', 'blik', 'cash'];
$carriers = ['dhl', 'inpost', 'dpd', 'ups', 'gls'];
$itemTypes = ['product', 'shipment', 'service'];
$currencies = ['PLN', 'EUR', 'USD'];
$sources = ['shop', 'marketplace', 'api'];
$noteTypes = ['customer_message', 'internal_note'];
$tags = ['priority', 'fraud-check', 'gift', 'invoice', 'vip', 'return-risk', 'express'];
$insertTag = $pdo->prepare(
'INSERT INTO order_tags_dict (integration_id, source_tag_id, tag_name, is_active, updated_at_external, payload_json, created_at, updated_at)
VALUES (:integration_id, :source_tag_id, :tag_name, :is_active, :updated_at_external, :payload_json, :created_at, :updated_at)'
);
foreach ($tags as $idx => $tagName) {
$insertTag->execute([
'integration_id' => random_int(1, 3),
'source_tag_id' => 'tag_' . ($idx + 1),
'tag_name' => $tagName,
'is_active' => 1,
'updated_at_external' => $now->format('Y-m-d H:i:s'),
'payload_json' => json_encode(['name' => $tagName], JSON_UNESCAPED_UNICODE),
'created_at' => $now->format('Y-m-d H:i:s'),
'updated_at' => $now->format('Y-m-d H:i:s'),
]);
}
$tagRows = $pdo->query('SELECT id FROM order_tags_dict')->fetchAll(PDO::FETCH_COLUMN);
$tagIds = array_map(static fn ($id) => (int) $id, is_array($tagRows) ? $tagRows : []);
$insertOrder = $pdo->prepare(
'INSERT INTO orders (
integration_id, source, source_order_id, external_order_id, external_platform_id, external_platform_account_id,
external_status_id, external_payment_type_id, payment_status, external_carrier_id, external_carrier_account_id,
customer_login, is_invoice, is_encrypted, is_canceled_by_buyer, currency, total_without_tax, total_with_tax,
total_paid, send_date_min, send_date_max, ordered_at, source_created_at, source_updated_at, preferences_json,
payload_json, fetched_at, created_at, updated_at
) VALUES (
:integration_id, :source, :source_order_id, :external_order_id, :external_platform_id, :external_platform_account_id,
:external_status_id, :external_payment_type_id, :payment_status, :external_carrier_id, :external_carrier_account_id,
:customer_login, :is_invoice, :is_encrypted, :is_canceled_by_buyer, :currency, :total_without_tax, :total_with_tax,
:total_paid, :send_date_min, :send_date_max, :ordered_at, :source_created_at, :source_updated_at, :preferences_json,
:payload_json, :fetched_at, :created_at, :updated_at
)'
);
$insertAddress = $pdo->prepare(
'INSERT INTO order_addresses (
order_id, address_type, name, phone, email, street_name, street_number, city, zip_code, country, department,
parcel_external_id, parcel_name, address_class, company_tax_number, company_name, payload_json, created_at, updated_at
) VALUES (
:order_id, :address_type, :name, :phone, :email, :street_name, :street_number, :city, :zip_code, :country, :department,
:parcel_external_id, :parcel_name, :address_class, :company_tax_number, :company_name, :payload_json, :created_at, :updated_at
)'
);
$insertItem = $pdo->prepare(
'INSERT INTO order_items (
order_id, source_item_id, external_item_id, ean, sku, original_name, original_code, original_price_with_tax,
original_price_without_tax, media_url, quantity, tax_rate, item_status, unit, item_type, source_product_id,
source_product_set_id, sort_order, payload_json, created_at, updated_at
) VALUES (
:order_id, :source_item_id, :external_item_id, :ean, :sku, :original_name, :original_code, :original_price_with_tax,
:original_price_without_tax, :media_url, :quantity, :tax_rate, :item_status, :unit, :item_type, :source_product_id,
:source_product_set_id, :sort_order, :payload_json, :created_at, :updated_at
)'
);
$insertPayment = $pdo->prepare(
'INSERT INTO order_payments (
order_id, source_payment_id, external_payment_id, payment_type_id, payment_date, amount, currency, comment,
payload_json, created_at, updated_at
) VALUES (
:order_id, :source_payment_id, :external_payment_id, :payment_type_id, :payment_date, :amount, :currency, :comment,
:payload_json, :created_at, :updated_at
)'
);
$insertShipment = $pdo->prepare(
'INSERT INTO order_shipments (
order_id, source_shipment_id, external_shipment_id, tracking_number, carrier_provider_id, posted_at, media_uuid,
payload_json, created_at, updated_at
) VALUES (
:order_id, :source_shipment_id, :external_shipment_id, :tracking_number, :carrier_provider_id, :posted_at, :media_uuid,
:payload_json, :created_at, :updated_at
)'
);
$insertDocument = $pdo->prepare(
'INSERT INTO order_documents (
order_id, source_document_id, external_document_id, document_number, price_with_tax, price_without_tax, currency,
currency_value, document_type_id, media_uuid, source_created_at, payload_json, created_at, updated_at
) VALUES (
:order_id, :source_document_id, :external_document_id, :document_number, :price_with_tax, :price_without_tax, :currency,
:currency_value, :document_type_id, :media_uuid, :source_created_at, :payload_json, :created_at, :updated_at
)'
);
$insertNote = $pdo->prepare(
'INSERT INTO order_notes (
order_id, source_note_id, note_type, created_at_external, comment, payload_json, created_at, updated_at
) VALUES (
:order_id, :source_note_id, :note_type, :created_at_external, :comment, :payload_json, :created_at, :updated_at
)'
);
$insertStatusHistory = $pdo->prepare(
'INSERT INTO order_status_history (
order_id, from_status_id, to_status_id, changed_at, change_source, comment, payload_json, created_at
) VALUES (
:order_id, :from_status_id, :to_status_id, :changed_at, :change_source, :comment, :payload_json, :created_at
)'
);
$insertTagLink = $pdo->prepare(
'INSERT INTO order_tag_links (order_id, tag_dict_id, assigned_at, payload_json, created_at)
VALUES (:order_id, :tag_dict_id, :assigned_at, :payload_json, :created_at)'
);
$firstNames = ['Jan', 'Anna', 'Piotr', 'Katarzyna', 'Marek', 'Ewa', 'Tomasz', 'Magda', 'Pawel', 'Karolina'];
$lastNames = ['Kowalski', 'Nowak', 'Wisniewski', 'Wojcik', 'Kaczmarek', 'Mazur', 'Krawczyk', 'Zielinski'];
$cities = ['Warszawa', 'Krakow', 'Gdansk', 'Poznan', 'Wroclaw', 'Lodz', 'Lublin', 'Katowice'];
$pdo->beginTransaction();
try {
for ($i = 1; $i <= $count; $i++) {
$integrationId = random_int(1, 3);
$source = $sources[array_rand($sources)];
$currency = $currencies[array_rand($currencies)];
$status = $statuses[array_rand($statuses)];
$paymentType = $paymentTypes[array_rand($paymentTypes)];
$carrier = $carriers[array_rand($carriers)];
$isInvoice = random_int(0, 1) === 1;
$orderedAt = $now->sub(new DateInterval('P' . random_int(0, 45) . 'DT' . random_int(0, 23) . 'H'));
$updatedAt = $orderedAt->add(new DateInterval('PT' . random_int(1, 120) . 'H'));
$sendMin = $orderedAt->add(new DateInterval('P' . random_int(0, 2) . 'D'));
$sendMax = $sendMin->add(new DateInterval('P' . random_int(0, 4) . 'D'));
$totalNet = (float) random_int(5000, 35000) / 100.0;
$totalGross = round($totalNet * 1.23, 2);
$paidRatio = [0.0, 0.5, 1.0, 1.1][array_rand([0, 1, 2, 3])];
$totalPaid = round($totalGross * $paidRatio, 2);
$paymentStatus = random_int(0, 3);
if ($profile === 'realistic') {
$status = weightedChoice([
'new' => 10,
'confirmed' => 16,
'paid' => 18,
'processing' => 16,
'packed' => 12,
'shipped' => 12,
'delivered' => 10,
'cancelled' => 4,
'returned' => 2,
]);
$paymentType = weightedChoice([
'transfer' => 30,
'card' => 30,
'blik' => 22,
'cod' => 14,
'cash' => 4,
]);
$carrier = weightedChoice([
'inpost' => 35,
'dhl' => 25,
'dpd' => 20,
'ups' => 12,
'gls' => 8,
]);
$isInvoice = random_int(1, 100) <= 35;
$orderedAt = $now->sub(new DateInterval('P' . random_int(0, 60) . 'DT' . random_int(0, 23) . 'H'));
$updatedAt = $orderedAt->add(new DateInterval('PT' . random_int(6, 168) . 'H'));
$sendMin = $orderedAt->add(new DateInterval('P' . random_int(0, 1) . 'D'));
$sendMax = $sendMin->add(new DateInterval('P' . random_int(1, 3) . 'D'));
$totalNet = (float) random_int(6000, 42000) / 100.0;
$totalGross = round($totalNet * 1.23, 2);
$statusFinancialRules = [
'new' => ['ratio' => [0.0, 0.0, 0.5], 'payment_status' => [0, 0, 1]],
'confirmed' => ['ratio' => [0.0, 0.5, 1.0], 'payment_status' => [0, 1, 2]],
'paid' => ['ratio' => [1.0, 1.0, 1.0, 0.5], 'payment_status' => [2, 2, 2, 1]],
'processing' => ['ratio' => [1.0, 1.0, 1.0], 'payment_status' => [2, 2, 2]],
'packed' => ['ratio' => [1.0, 1.0, 1.0], 'payment_status' => [2, 2, 2]],
'shipped' => ['ratio' => [1.0, 1.0, 1.0], 'payment_status' => [2, 2, 2]],
'delivered' => ['ratio' => [1.0, 1.0, 1.0], 'payment_status' => [2, 2, 2]],
'cancelled' => ['ratio' => [0.0, 0.0, 0.5], 'payment_status' => [0, 0, 1]],
'returned' => ['ratio' => [1.0, 1.0, 0.0], 'payment_status' => [3, 3, 0]],
];
if (isset($statusFinancialRules[$status])) {
$rule = $statusFinancialRules[$status];
$paidRatio = (float) $rule['ratio'][array_rand($rule['ratio'])];
$paymentStatus = (int) $rule['payment_status'][array_rand($rule['payment_status'])];
} else {
$paidRatio = [0.0, 0.5, 1.0][array_rand([0, 1, 2])];
$paymentStatus = [0, 1, 2][array_rand([0, 1, 2])];
}
$totalPaid = round($totalGross * $paidRatio, 2);
if ($status === 'returned' && $paymentStatus === 3) {
$totalPaid = 0.00;
}
}
$sourceOrderId = 'ORD-' . $integrationId . '-' . $orderedAt->format('Ymd') . '-' . str_pad((string) $i, 4, '0', STR_PAD_LEFT);
$externalOrderId = 'EXT-' . random_int(100000, 999999);
$platformId = 'platform_' . random_int(1, 5);
$platformAccountId = 'account_' . random_int(1, 8);
$customerName = $firstNames[array_rand($firstNames)] . ' ' . $lastNames[array_rand($lastNames)];
$customerEmail = strtolower(str_replace(' ', '.', $customerName)) . random_int(1, 99) . '@example.com';
$city = $cities[array_rand($cities)];
$insertOrder->execute([
'integration_id' => $integrationId,
'source' => $source,
'source_order_id' => $sourceOrderId,
'external_order_id' => $externalOrderId,
'external_platform_id' => $platformId,
'external_platform_account_id' => $platformAccountId,
'external_status_id' => $status,
'external_payment_type_id' => $paymentType,
'payment_status' => $paymentStatus,
'external_carrier_id' => $carrier,
'external_carrier_account_id' => 'carrier_acc_' . random_int(1, 6),
'customer_login' => strtolower(str_replace(' ', '.', $customerName)),
'is_invoice' => $isInvoice ? 1 : 0,
'is_encrypted' => random_int(0, 1),
'is_canceled_by_buyer' => $status === 'cancelled' ? 1 : 0,
'currency' => $currency,
'total_without_tax' => number_format($totalNet, 2, '.', ''),
'total_with_tax' => number_format($totalGross, 2, '.', ''),
'total_paid' => number_format($totalPaid, 2, '.', ''),
'send_date_min' => $sendMin->format('Y-m-d H:i:s'),
'send_date_max' => $sendMax->format('Y-m-d H:i:s'),
'ordered_at' => $orderedAt->format('Y-m-d H:i:s'),
'source_created_at' => $orderedAt->format('Y-m-d H:i:s'),
'source_updated_at' => $updatedAt->format('Y-m-d H:i:s'),
'preferences_json' => json_encode(['gift_wrap' => (bool) random_int(0, 1)], JSON_UNESCAPED_UNICODE),
'payload_json' => json_encode(['mock' => true, 'v' => 1], JSON_UNESCAPED_UNICODE),
'fetched_at' => $now->format('Y-m-d H:i:s'),
'created_at' => $now->format('Y-m-d H:i:s'),
'updated_at' => $now->format('Y-m-d H:i:s'),
]);
$orderId = (int) $pdo->lastInsertId();
foreach (['customer', 'delivery'] as $addressType) {
$insertAddress->execute([
'order_id' => $orderId,
'address_type' => $addressType,
'name' => $customerName,
'phone' => '+485' . random_int(10000000, 99999999),
'email' => $customerEmail,
'street_name' => 'Testowa',
'street_number' => (string) random_int(1, 120),
'city' => $city,
'zip_code' => sprintf('%02d-%03d', random_int(10, 99), random_int(100, 999)),
'country' => 'PL',
'department' => null,
'parcel_external_id' => random_int(0, 3) === 0 ? 'PACZ-' . random_int(1000, 9999) : null,
'parcel_name' => null,
'address_class' => $isInvoice ? 'company' : 'house',
'company_tax_number' => $isInvoice ? 'PL' . random_int(1000000000, 9999999999) : null,
'company_name' => $isInvoice ? 'Firma ' . $customerName : null,
'payload_json' => json_encode(['type' => $addressType], JSON_UNESCAPED_UNICODE),
'created_at' => $now->format('Y-m-d H:i:s'),
'updated_at' => $now->format('Y-m-d H:i:s'),
]);
}
if ($isInvoice) {
$insertAddress->execute([
'order_id' => $orderId,
'address_type' => 'invoice',
'name' => $customerName,
'phone' => '+485' . random_int(10000000, 99999999),
'email' => $customerEmail,
'street_name' => 'Firmowa',
'street_number' => (string) random_int(1, 120),
'city' => $city,
'zip_code' => sprintf('%02d-%03d', random_int(10, 99), random_int(100, 999)),
'country' => 'PL',
'department' => null,
'parcel_external_id' => null,
'parcel_name' => null,
'address_class' => 'company',
'company_tax_number' => 'PL' . random_int(1000000000, 9999999999),
'company_name' => 'Firma ' . $customerName,
'payload_json' => json_encode(['type' => 'invoice'], JSON_UNESCAPED_UNICODE),
'created_at' => $now->format('Y-m-d H:i:s'),
'updated_at' => $now->format('Y-m-d H:i:s'),
]);
}
$itemsCount = random_int(1, 5);
for ($j = 1; $j <= $itemsCount; $j++) {
$itemType = $itemTypes[array_rand($itemTypes)];
$qty = $itemType === 'product' ? random_int(1, 3) : 1;
$priceGross = (float) random_int(1500, 12000) / 100.0;
$priceNet = round($priceGross / 1.23, 2);
$insertItem->execute([
'order_id' => $orderId,
'source_item_id' => 'ITEM-' . $orderId . '-' . $j,
'external_item_id' => 'EXTI-' . random_int(10000, 99999),
'ean' => (string) random_int(1000000000000, 9999999999999),
'sku' => 'SKU-' . random_int(10000, 99999),
'original_name' => ucfirst($itemType) . ' ' . random_int(1, 999),
'original_code' => strtoupper(substr($itemType, 0, 3)) . '-' . random_int(100, 999),
'original_price_with_tax' => number_format($priceGross, 2, '.', ''),
'original_price_without_tax' => number_format($priceNet, 2, '.', ''),
'media_url' => random_int(1, 100) <= 85
? ('https://picsum.photos/seed/order-item-' . $orderId . '-' . $j . '/120/120')
: null,
'quantity' => number_format((float) $qty, 3, '.', ''),
'tax_rate' => 23.00,
'item_status' => 'active',
'unit' => 'szt.',
'item_type' => $itemType,
'source_product_id' => 'P-' . random_int(1000, 9999),
'source_product_set_id' => null,
'sort_order' => $j - 1,
'payload_json' => json_encode(['idx' => $j], JSON_UNESCAPED_UNICODE),
'created_at' => $now->format('Y-m-d H:i:s'),
'updated_at' => $now->format('Y-m-d H:i:s'),
]);
}
$paymentsCount = random_int(1, 2);
if ($profile === 'realistic') {
if ($totalPaid <= 0.00) {
$paymentsCount = random_int(0, 1) === 0 ? 0 : 1;
} elseif ($totalPaid < $totalGross) {
$paymentsCount = 1;
} else {
$paymentsCount = random_int(1, 100) <= 75 ? 1 : 2;
}
}
if ($paymentsCount > 0) {
for ($j = 1; $j <= $paymentsCount; $j++) {
$amount = $paymentsCount === 1 || $j < $paymentsCount
? round($totalPaid / $paymentsCount, 2)
: round($totalPaid - round(($totalPaid / $paymentsCount) * ($paymentsCount - 1), 2), 2);
$insertPayment->execute([
'order_id' => $orderId,
'source_payment_id' => 'PAY-' . $orderId . '-' . $j,
'external_payment_id' => 'EXTP-' . random_int(10000, 99999),
'payment_type_id' => $paymentType,
'payment_date' => $orderedAt->add(new DateInterval('PT' . random_int(0, 72) . 'H'))->format('Y-m-d H:i:s'),
'amount' => number_format(max(0.0, $amount), 2, '.', ''),
'currency' => $currency,
'comment' => 'Payment #' . $j,
'payload_json' => json_encode(['part' => $j], JSON_UNESCAPED_UNICODE),
'created_at' => $now->format('Y-m-d H:i:s'),
'updated_at' => $now->format('Y-m-d H:i:s'),
]);
}
}
$shipmentsCount = random_int(0, 2);
if ($profile === 'realistic') {
if (in_array($status, ['shipped', 'delivered', 'returned'], true)) {
$shipmentsCount = random_int(1, 2);
} elseif ($status === 'packed') {
$shipmentsCount = random_int(0, 100) <= 35 ? 1 : 0;
} else {
$shipmentsCount = 0;
}
}
for ($j = 1; $j <= $shipmentsCount; $j++) {
$insertShipment->execute([
'order_id' => $orderId,
'source_shipment_id' => 'SHIP-' . $orderId . '-' . $j,
'external_shipment_id' => 'EXTS-' . random_int(10000, 99999),
'tracking_number' => 'TRK' . random_int(100000000, 999999999),
'carrier_provider_id' => $carriers[array_rand($carriers)],
'posted_at' => $orderedAt->add(new DateInterval('P' . random_int(1, 6) . 'D'))->format('Y-m-d H:i:s'),
'media_uuid' => null,
'payload_json' => json_encode(['shipment_no' => $j], JSON_UNESCAPED_UNICODE),
'created_at' => $now->format('Y-m-d H:i:s'),
'updated_at' => $now->format('Y-m-d H:i:s'),
]);
}
$documentsCount = random_int(0, 2);
if ($profile === 'realistic') {
if ($isInvoice || in_array($status, ['paid', 'processing', 'packed', 'shipped', 'delivered'], true)) {
$documentsCount = random_int(1, 100) <= 75 ? 1 : 2;
} else {
$documentsCount = random_int(1, 100) <= 15 ? 1 : 0;
}
}
for ($j = 1; $j <= $documentsCount; $j++) {
$insertDocument->execute([
'order_id' => $orderId,
'source_document_id' => 'DOC-' . $orderId . '-' . $j,
'external_document_id' => 'EXTD-' . random_int(10000, 99999),
'document_number' => 'FV/' . random_int(1, 9999) . '/' . $now->format('Y'),
'price_with_tax' => number_format($totalGross, 2, '.', ''),
'price_without_tax' => number_format($totalNet, 2, '.', ''),
'currency' => $currency,
'currency_value' => number_format(1.0, 4, '.', ''),
'document_type_id' => 'invoice',
'media_uuid' => null,
'source_created_at' => $orderedAt->add(new DateInterval('P' . random_int(0, 3) . 'D'))->format('Y-m-d H:i:s'),
'payload_json' => json_encode(['document_no' => $j], JSON_UNESCAPED_UNICODE),
'created_at' => $now->format('Y-m-d H:i:s'),
'updated_at' => $now->format('Y-m-d H:i:s'),
]);
}
$notesCount = random_int(0, 3);
for ($j = 1; $j <= $notesCount; $j++) {
$insertNote->execute([
'order_id' => $orderId,
'source_note_id' => 'NOTE-' . $orderId . '-' . $j,
'note_type' => $noteTypes[array_rand($noteTypes)],
'created_at_external' => $orderedAt->add(new DateInterval('PT' . random_int(1, 96) . 'H'))->format('Y-m-d H:i:s'),
'comment' => 'Losowa notatka #' . $j . ' dla zamowienia ' . $sourceOrderId,
'payload_json' => json_encode(['n' => $j], JSON_UNESCAPED_UNICODE),
'created_at' => $now->format('Y-m-d H:i:s'),
'updated_at' => $now->format('Y-m-d H:i:s'),
]);
}
if ($profile === 'realistic') {
$historyPath = buildStatusPathForFinal($status);
$historyDate = $orderedAt;
for ($j = 1; $j < count($historyPath); $j++) {
$historyDate = $historyDate->add(new DateInterval('PT' . random_int(2, 30) . 'H'));
$insertStatusHistory->execute([
'order_id' => $orderId,
'from_status_id' => $historyPath[$j - 1],
'to_status_id' => $historyPath[$j],
'changed_at' => $historyDate->format('Y-m-d H:i:s'),
'change_source' => ['import', 'manual', 'api', 'sync'][array_rand([0, 1, 2, 3])],
'comment' => 'Auto-seed status change',
'payload_json' => json_encode(['step' => $j], JSON_UNESCAPED_UNICODE),
'created_at' => $now->format('Y-m-d H:i:s'),
]);
}
} else {
$historySteps = random_int(2, 6);
$currentStatus = 'new';
$historyDate = $orderedAt;
for ($j = 1; $j <= $historySteps; $j++) {
$nextStatus = $statuses[array_rand($statuses)];
$historyDate = $historyDate->add(new DateInterval('PT' . random_int(2, 30) . 'H'));
$insertStatusHistory->execute([
'order_id' => $orderId,
'from_status_id' => $currentStatus,
'to_status_id' => $nextStatus,
'changed_at' => $historyDate->format('Y-m-d H:i:s'),
'change_source' => ['import', 'manual', 'api', 'sync'][array_rand([0, 1, 2, 3])],
'comment' => 'Auto-seed status change',
'payload_json' => json_encode(['step' => $j], JSON_UNESCAPED_UNICODE),
'created_at' => $now->format('Y-m-d H:i:s'),
]);
$currentStatus = $nextStatus;
}
}
shuffle($tagIds);
$assignCount = random_int(0, min(3, count($tagIds)));
for ($j = 0; $j < $assignCount; $j++) {
$insertTagLink->execute([
'order_id' => $orderId,
'tag_dict_id' => $tagIds[$j],
'assigned_at' => $orderedAt->add(new DateInterval('PT' . random_int(1, 20) . 'H'))->format('Y-m-d H:i:s'),
'payload_json' => json_encode(['auto' => true], JSON_UNESCAPED_UNICODE),
'created_at' => $now->format('Y-m-d H:i:s'),
]);
}
}
$syncInsert = $pdo->prepare(
'INSERT INTO integration_order_sync_state (
integration_id, last_synced_order_updated_at, last_synced_source_order_id, last_synced_external_order_id,
last_run_at, last_success_at, last_error, created_at, updated_at
) VALUES (
:integration_id, :last_synced_order_updated_at, :last_synced_source_order_id, :last_synced_external_order_id,
:last_run_at, :last_success_at, :last_error, :created_at, :updated_at
)'
);
for ($i = 1; $i <= 3; $i++) {
$syncInsert->execute([
'integration_id' => $i,
'last_synced_order_updated_at' => $now->format('Y-m-d H:i:s'),
'last_synced_source_order_id' => 'ORD-' . $i . '-' . $now->format('Ymd') . '-9999',
'last_synced_external_order_id' => 'EXT-' . random_int(100000, 999999),
'last_run_at' => $now->format('Y-m-d H:i:s'),
'last_success_at' => $now->format('Y-m-d H:i:s'),
'last_error' => null,
'created_at' => $now->format('Y-m-d H:i:s'),
'updated_at' => $now->format('Y-m-d H:i:s'),
]);
}
$pdo->commit();
} catch (Throwable $exception) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $exception;
}
echo '[seed] inserted orders and child data' . PHP_EOL;
}
/**
* @param array<string,int> $weights
*/
function weightedChoice(array $weights): string
{
$sum = array_sum($weights);
if ($sum <= 0) {
throw new RuntimeException('Invalid weights.');
}
$pick = random_int(1, $sum);
$running = 0;
foreach ($weights as $value => $weight) {
$running += $weight;
if ($pick <= $running) {
return (string) $value;
}
}
return (string) array_key_first($weights);
}
/**
* @return list<string>
*/
function buildStatusPathForFinal(string $finalStatus): array
{
$happyPath = ['new', 'confirmed', 'paid', 'processing', 'packed', 'shipped', 'delivered'];
if ($finalStatus === 'new') {
return ['new'];
}
if (in_array($finalStatus, $happyPath, true)) {
$index = array_search($finalStatus, $happyPath, true);
return array_slice($happyPath, 0, ((int) $index) + 1);
}
if ($finalStatus === 'cancelled') {
$variants = [
['new', 'cancelled'],
['new', 'confirmed', 'cancelled'],
['new', 'confirmed', 'paid', 'cancelled'],
['new', 'confirmed', 'paid', 'processing', 'cancelled'],
];
return $variants[array_rand($variants)];
}
if ($finalStatus === 'returned') {
return ['new', 'confirmed', 'paid', 'processing', 'packed', 'shipped', 'delivered', 'returned'];
}
return ['new', $finalStatus];
}
/**
* @return list<string>
*/
function loadStatusesForSeed(PDO $pdo): array
{
$fallback = ['new', 'confirmed', 'paid', 'processing', 'packed', 'shipped', 'delivered', 'cancelled', 'returned'];
try {
$rows = $pdo->query('SELECT code FROM order_statuses WHERE is_active = 1 ORDER BY sort_order ASC, id ASC')->fetchAll(PDO::FETCH_COLUMN);
} catch (Throwable) {
return $fallback;
}
if (!is_array($rows)) {
return $fallback;
}
$result = [];
foreach ($rows as $row) {
$code = strtolower(trim((string) $row));
if ($code === '' || in_array($code, $result, true)) {
continue;
}
$result[] = $code;
}
return $result === [] ? $fallback : $result;
}
function printSummary(PDO $pdo): void
{
$tables = [
'orders',
'order_addresses',
'order_items',
'order_payments',
'order_shipments',
'order_documents',
'order_notes',
'order_status_history',
'order_tags_dict',
'order_tag_links',
'integration_order_sync_state',
];
echo 'Summary counts:' . PHP_EOL;
foreach ($tables as $table) {
$count = (int) $pdo->query('SELECT COUNT(*) FROM ' . $table)->fetchColumn();
echo ' ' . $table . ': ' . $count . PHP_EOL;
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
use App\Core\Database\ConnectionFactory;
use App\Core\Support\Env;
$basePath = dirname(__DIR__);
$vendorAutoload = $basePath . '/vendor/autoload.php';
if (is_file($vendorAutoload)) {
require $vendorAutoload;
} else {
spl_autoload_register(static function (string $class) use ($basePath): void {
$prefix = 'App\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relative = substr($class, strlen($prefix));
$file = $basePath . '/src/' . str_replace('\\', '/', $relative) . '.php';
if (is_file($file)) {
require $file;
}
});
}
Env::load($basePath . '/.env');
/** @var array<string, mixed> $dbConfig */
$dbConfig = require $basePath . '/config/database.php';
$args = $argv;
array_shift($args);
$useRemote = in_array('--use-remote', $args, true);
if ($useRemote) {
$remoteHost = (string) Env::get('DB_HOST_REMOTE', '');
if ($remoteHost !== '') {
$dbConfig['host'] = $remoteHost;
}
}
$pdo = ConnectionFactory::make($dbConfig);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo 'Fill order item images' . PHP_EOL;
echo '[db-host] ' . (string) ($dbConfig['host'] ?? '') . PHP_EOL;
$rows = $pdo->query('SELECT id FROM order_items WHERE media_url IS NULL OR media_url = ""')->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows) || $rows === []) {
echo '[result] nothing to update' . PHP_EOL;
exit(0);
}
$update = $pdo->prepare('UPDATE order_items SET media_url = :media_url, updated_at = NOW() WHERE id = :id');
$updated = 0;
$pdo->beginTransaction();
try {
foreach ($rows as $row) {
$id = max(0, (int) ($row['id'] ?? 0));
if ($id <= 0) {
continue;
}
$update->execute([
'media_url' => 'https://picsum.photos/seed/order-item-' . $id . '/120/120',
'id' => $id,
]);
$updated++;
}
$pdo->commit();
} catch (Throwable $exception) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
fwrite(STDERR, '[error] ' . $exception->getMessage() . PHP_EOL);
exit(1);
}
echo '[updated] ' . $updated . PHP_EOL;
echo 'Done.' . PHP_EOL;

216
bin/fix_status_codes.php Normal file
View File

@@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
use App\Core\Database\ConnectionFactory;
use App\Core\Support\Env;
$basePath = dirname(__DIR__);
$vendorAutoload = $basePath . '/vendor/autoload.php';
if (is_file($vendorAutoload)) {
require $vendorAutoload;
} else {
spl_autoload_register(static function (string $class) use ($basePath): void {
$prefix = 'App\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relative = substr($class, strlen($prefix));
$file = $basePath . '/src/' . str_replace('\\', '/', $relative) . '.php';
if (is_file($file)) {
require $file;
}
});
}
Env::load($basePath . '/.env');
/** @var array<string, mixed> $dbConfig */
$dbConfig = require $basePath . '/config/database.php';
$dryRun = in_array('--dry-run', $argv, true);
$useRemote = in_array('--use-remote', $argv, true);
if ($useRemote) {
$remoteHost = (string) Env::get('DB_HOST_REMOTE', '');
if ($remoteHost !== '') {
$dbConfig['host'] = $remoteHost;
}
}
$pdo = ConnectionFactory::make($dbConfig);
echo 'Fix status codes script' . PHP_EOL;
echo $dryRun ? '[mode] dry-run (no DB changes)' . PHP_EOL : '[mode] apply' . PHP_EOL;
if ($useRemote) {
echo '[db] using DB_HOST_REMOTE for this run' . PHP_EOL;
}
try {
$resultGroups = fixGroupCodes($pdo, $dryRun);
$resultStatuses = fixStatusCodes($pdo, $dryRun);
echo PHP_EOL . 'Summary:' . PHP_EOL;
echo ' groups updated: ' . $resultGroups['updated'] . PHP_EOL;
echo ' statuses updated: ' . $resultStatuses['updated'] . PHP_EOL;
echo 'Done.' . PHP_EOL;
} catch (Throwable $exception) {
fwrite(STDERR, '[error] ' . $exception->getMessage() . PHP_EOL);
exit(1);
}
/**
* @return array{updated:int}
*/
function fixGroupCodes(PDO $pdo, bool $dryRun): array
{
$stmt = $pdo->query('SELECT id, name, code FROM order_status_groups ORDER BY id ASC');
$rows = $stmt !== false ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
if (!is_array($rows)) {
$rows = [];
}
$usedCodes = [];
foreach ($rows as $row) {
$existing = strtolower(trim((string) ($row['code'] ?? '')));
if ($existing !== '') {
$usedCodes[$existing] = true;
}
}
$updated = 0;
$updateStmt = $pdo->prepare('UPDATE order_status_groups SET code = :code, updated_at = :updated_at WHERE id = :id');
foreach ($rows as $row) {
$id = max(0, (int) ($row['id'] ?? 0));
if ($id <= 0) {
continue;
}
$currentCode = strtolower(trim((string) ($row['code'] ?? '')));
$base = normalizeCodeFromName((string) ($row['name'] ?? ''), 'group_' . $id);
$targetCode = ensureUniqueCode($base, $currentCode, $usedCodes);
if ($targetCode === $currentCode) {
continue;
}
echo ' [group #' . $id . '] ' . ($currentCode !== '' ? $currentCode : '(empty)') . ' -> ' . $targetCode . PHP_EOL;
if (!$dryRun) {
$updateStmt->execute([
'id' => $id,
'code' => $targetCode,
'updated_at' => date('Y-m-d H:i:s'),
]);
}
if ($currentCode !== '') {
unset($usedCodes[$currentCode]);
}
$usedCodes[$targetCode] = true;
$updated++;
}
return ['updated' => $updated];
}
/**
* @return array{updated:int}
*/
function fixStatusCodes(PDO $pdo, bool $dryRun): array
{
$stmt = $pdo->query('SELECT id, name, code FROM order_statuses ORDER BY id ASC');
$rows = $stmt !== false ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
if (!is_array($rows)) {
$rows = [];
}
$usedCodes = [];
foreach ($rows as $row) {
$existing = strtolower(trim((string) ($row['code'] ?? '')));
if ($existing !== '') {
$usedCodes[$existing] = true;
}
}
$updated = 0;
$updateStmt = $pdo->prepare('UPDATE order_statuses SET code = :code, updated_at = :updated_at WHERE id = :id');
foreach ($rows as $row) {
$id = max(0, (int) ($row['id'] ?? 0));
if ($id <= 0) {
continue;
}
$currentCode = strtolower(trim((string) ($row['code'] ?? '')));
$base = normalizeCodeFromName((string) ($row['name'] ?? ''), 'status_' . $id);
$targetCode = ensureUniqueCode($base, $currentCode, $usedCodes);
if ($targetCode === $currentCode) {
continue;
}
echo ' [status #' . $id . '] ' . ($currentCode !== '' ? $currentCode : '(empty)') . ' -> ' . $targetCode . PHP_EOL;
if (!$dryRun) {
$updateStmt->execute([
'id' => $id,
'code' => $targetCode,
'updated_at' => date('Y-m-d H:i:s'),
]);
}
if ($currentCode !== '') {
unset($usedCodes[$currentCode]);
}
$usedCodes[$targetCode] = true;
$updated++;
}
return ['updated' => $updated];
}
/**
* @param array<string, bool> $usedCodes
*/
function ensureUniqueCode(string $base, string $currentCode, array $usedCodes): string
{
$normalizedCurrent = strtolower(trim($currentCode));
$candidate = $base;
$index = 2;
while ($candidate !== $normalizedCurrent && isset($usedCodes[$candidate])) {
$suffix = '_' . $index;
$candidate = mb_substr($base, 0, max(1, 64 - mb_strlen($suffix))) . $suffix;
$index++;
}
return $candidate;
}
function normalizeCodeFromName(string $name, string $fallback): string
{
$value = mb_strtolower(trim($name));
if ($value === '') {
$value = $fallback;
}
$value = strtr($value, [
mb_chr(261, 'UTF-8') => 'a', // ą
mb_chr(263, 'UTF-8') => 'c', // ć
mb_chr(281, 'UTF-8') => 'e', // ę
mb_chr(322, 'UTF-8') => 'l', // ł
mb_chr(324, 'UTF-8') => 'n', // ń
mb_chr(243, 'UTF-8') => 'o', // ó
mb_chr(347, 'UTF-8') => 's', // ś
mb_chr(378, 'UTF-8') => 'z', // ź
mb_chr(380, 'UTF-8') => 'z', // ż
]);
$value = preg_replace('/[^a-z0-9_\-\.]+/u', '_', $value) ?? '';
$value = trim($value, '_');
if ($value === '') {
$value = $fallback;
}
return mb_substr($value, 0, 64);
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
use App\Core\Database\ConnectionFactory;
use App\Core\Support\Env;
$basePath = dirname(__DIR__);
$vendorAutoload = $basePath . '/vendor/autoload.php';
if (is_file($vendorAutoload)) {
require $vendorAutoload;
} else {
spl_autoload_register(static function (string $class) use ($basePath): void {
$prefix = 'App\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relative = substr($class, strlen($prefix));
$file = $basePath . '/src/' . str_replace('\\', '/', $relative) . '.php';
if (is_file($file)) {
require $file;
}
});
}
Env::load($basePath . '/.env');
/** @var array<string, mixed> $dbConfig */
$dbConfig = require $basePath . '/config/database.php';
$args = $argv;
array_shift($args);
$useRemote = in_array('--use-remote', $args, true);
$dryRun = in_array('--dry-run', $args, true);
if ($useRemote) {
$remoteHost = (string) Env::get('DB_HOST_REMOTE', '');
if ($remoteHost !== '') {
$dbConfig['host'] = $remoteHost;
}
}
$pdo = ConnectionFactory::make($dbConfig);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo 'Randomize order statuses' . PHP_EOL;
echo '[db-host] ' . (string) ($dbConfig['host'] ?? '') . PHP_EOL;
echo '[mode] ' . ($dryRun ? 'dry-run' : 'apply') . PHP_EOL;
$statusRows = $pdo->query('SELECT code FROM order_statuses WHERE is_active = 1 ORDER BY sort_order ASC, id ASC')->fetchAll(PDO::FETCH_COLUMN);
if (!is_array($statusRows) || $statusRows === []) {
fwrite(STDERR, '[error] Brak aktywnych statusow w tabeli order_statuses.' . PHP_EOL);
exit(1);
}
$statuses = [];
foreach ($statusRows as $row) {
$code = strtolower(trim((string) $row));
if ($code === '' || in_array($code, $statuses, true)) {
continue;
}
$statuses[] = $code;
}
if ($statuses === []) {
fwrite(STDERR, '[error] Nie znaleziono poprawnych kodow statusow.' . PHP_EOL);
exit(1);
}
$ordersRows = $pdo->query('SELECT id, external_status_id FROM orders ORDER BY id ASC')->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($ordersRows) || $ordersRows === []) {
echo '[result] Brak zamowien do aktualizacji.' . PHP_EOL;
exit(0);
}
$preview = [];
$changes = 0;
$updateStmt = $pdo->prepare('UPDATE orders SET external_status_id = :status, is_canceled_by_buyer = :is_canceled_by_buyer, updated_at = NOW() WHERE id = :id');
if (!$dryRun) {
$pdo->beginTransaction();
}
try {
foreach ($ordersRows as $row) {
$id = max(0, (int) ($row['id'] ?? 0));
if ($id <= 0) {
continue;
}
$old = strtolower(trim((string) ($row['external_status_id'] ?? '')));
$new = $statuses[array_rand($statuses)];
if (count($preview) < 8) {
$preview[] = ['id' => $id, 'old' => $old, 'new' => $new];
}
if (!$dryRun) {
$updateStmt->execute([
'status' => $new,
'is_canceled_by_buyer' => $new === 'cancelled' ? 1 : 0,
'id' => $id,
]);
}
$changes++;
}
if (!$dryRun && $pdo->inTransaction()) {
$pdo->commit();
}
} catch (Throwable $exception) {
if (!$dryRun && $pdo->inTransaction()) {
$pdo->rollBack();
}
fwrite(STDERR, '[error] ' . $exception->getMessage() . PHP_EOL);
exit(1);
}
echo '[statuses] ' . implode(', ', $statuses) . PHP_EOL;
echo '[updated_orders] ' . $changes . PHP_EOL;
echo '[preview]' . PHP_EOL;
foreach ($preview as $row) {
echo ' #' . $row['id'] . ': ' . $row['old'] . ' -> ' . $row['new'] . PHP_EOL;
}
echo 'Done.' . PHP_EOL;

View File

@@ -6,14 +6,23 @@
"require": {
"php": "^8.4"
},
"require-dev": {
"phpunit/phpunit": "^11.5"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"serve": "php -S localhost:8000 -t public public/index.php",
"migrate": "php bin/migrate.php",
"cron": "php bin/cron.php"
"cron": "php bin/cron.php",
"test": "vendor/bin/phpunit -c phpunit.xml --testdox"
}
}

View File

@@ -0,0 +1,240 @@
-- Draft schema for generic orders domain (not auto-run by Migrator).
-- Source API fields were inspired by external marketplace/order APIs (Apilo used only as example).
CREATE TABLE IF NOT EXISTS orders (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
integration_id INT UNSIGNED NULL,
source VARCHAR(32) NOT NULL DEFAULT 'external',
source_order_id VARCHAR(64) NOT NULL,
external_order_id VARCHAR(128) NULL,
external_platform_id VARCHAR(64) NULL,
external_platform_account_id VARCHAR(64) NULL,
external_status_id VARCHAR(64) NULL,
external_payment_type_id VARCHAR(64) NULL,
payment_status TINYINT UNSIGNED NULL,
external_carrier_id VARCHAR(64) NULL,
external_carrier_account_id VARCHAR(64) NULL,
customer_login VARCHAR(128) NULL,
is_invoice TINYINT(1) NOT NULL DEFAULT 0,
is_encrypted TINYINT(1) NOT NULL DEFAULT 0,
is_canceled_by_buyer TINYINT(1) NOT NULL DEFAULT 0,
currency CHAR(3) NOT NULL,
total_without_tax DECIMAL(12,2) NULL,
total_with_tax DECIMAL(12,2) NULL,
total_paid DECIMAL(12,2) NULL,
send_date_min DATETIME NULL,
send_date_max DATETIME NULL,
ordered_at DATETIME NULL,
source_created_at DATETIME NULL,
source_updated_at DATETIME NULL,
preferences_json JSON NULL,
payload_json JSON NULL,
fetched_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY orders_integration_source_order_unique (integration_id, source_order_id),
KEY orders_integration_external_idx (integration_id, external_order_id),
KEY orders_status_idx (external_status_id),
KEY orders_source_updated_idx (source_updated_at),
KEY orders_ordered_at_idx (ordered_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS order_addresses (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_id BIGINT UNSIGNED NOT NULL,
address_type ENUM('customer', 'delivery', 'invoice') NOT NULL,
name VARCHAR(128) NOT NULL,
phone VARCHAR(64) NULL,
email VARCHAR(128) NULL,
street_name VARCHAR(128) NULL,
street_number VARCHAR(32) NULL,
city VARCHAR(128) NULL,
zip_code VARCHAR(16) NULL,
country CHAR(2) NULL,
department VARCHAR(128) NULL,
parcel_external_id VARCHAR(64) NULL,
parcel_name VARCHAR(128) NULL,
address_class VARCHAR(32) NULL,
company_tax_number VARCHAR(64) NULL,
company_name VARCHAR(128) NULL,
payload_json JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY order_addresses_order_type_unique (order_id, address_type),
KEY order_addresses_email_idx (email),
CONSTRAINT order_addresses_order_fk
FOREIGN KEY (order_id) REFERENCES orders(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS order_items (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_id BIGINT UNSIGNED NOT NULL,
source_item_id VARCHAR(64) NULL,
external_item_id VARCHAR(64) NULL,
ean VARCHAR(32) NULL,
sku VARCHAR(128) NULL,
original_name VARCHAR(255) NOT NULL,
original_code VARCHAR(128) NULL,
original_price_with_tax DECIMAL(12,2) NULL,
original_price_without_tax DECIMAL(12,2) NULL,
media_url VARCHAR(512) NULL,
quantity DECIMAL(12,3) NOT NULL DEFAULT 1.000,
tax_rate DECIMAL(6,2) NULL,
item_status VARCHAR(32) NULL,
unit VARCHAR(16) NULL,
item_type VARCHAR(32) NOT NULL COMMENT 'e.g. product, shipment, service',
source_product_id VARCHAR(64) NULL,
source_product_set_id VARCHAR(64) NULL,
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
payload_json JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY order_items_order_source_item_unique (order_id, source_item_id),
KEY order_items_order_idx (order_id),
KEY order_items_sku_idx (sku),
KEY order_items_ean_idx (ean),
CONSTRAINT order_items_order_fk
FOREIGN KEY (order_id) REFERENCES orders(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS order_payments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_id BIGINT UNSIGNED NOT NULL,
source_payment_id VARCHAR(64) NULL,
external_payment_id VARCHAR(64) NULL,
payment_type_id VARCHAR(64) NOT NULL,
payment_date DATETIME NULL,
amount DECIMAL(12,2) NULL,
currency CHAR(3) NULL,
comment VARCHAR(255) NULL,
payload_json JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY order_payments_order_source_payment_unique (order_id, source_payment_id),
KEY order_payments_order_idx (order_id),
KEY order_payments_date_idx (payment_date),
CONSTRAINT order_payments_order_fk
FOREIGN KEY (order_id) REFERENCES orders(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS order_shipments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_id BIGINT UNSIGNED NOT NULL,
source_shipment_id VARCHAR(64) NULL,
external_shipment_id VARCHAR(64) NULL,
tracking_number VARCHAR(128) NOT NULL,
carrier_provider_id VARCHAR(64) NOT NULL,
posted_at DATETIME NULL,
media_uuid CHAR(36) NULL,
payload_json JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY order_shipments_order_source_shipment_unique (order_id, source_shipment_id),
KEY order_shipments_order_idx (order_id),
KEY order_shipments_tracking_idx (tracking_number),
CONSTRAINT order_shipments_order_fk
FOREIGN KEY (order_id) REFERENCES orders(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS order_documents (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_id BIGINT UNSIGNED NOT NULL,
source_document_id VARCHAR(64) NULL,
external_document_id VARCHAR(64) NULL,
document_number VARCHAR(64) NULL,
price_with_tax DECIMAL(12,2) NULL,
price_without_tax DECIMAL(12,2) NULL,
currency CHAR(3) NULL,
currency_value DECIMAL(12,4) NULL,
document_type_id VARCHAR(64) NULL,
media_uuid CHAR(36) NULL,
source_created_at DATETIME NULL,
payload_json JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY order_documents_order_source_document_unique (order_id, source_document_id),
KEY order_documents_order_idx (order_id),
KEY order_documents_number_idx (document_number),
CONSTRAINT order_documents_order_fk
FOREIGN KEY (order_id) REFERENCES orders(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS order_notes (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_id BIGINT UNSIGNED NOT NULL,
source_note_id VARCHAR(64) NULL,
note_type VARCHAR(32) NOT NULL,
created_at_external DATETIME NULL,
comment TEXT NOT NULL,
payload_json JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY order_notes_order_source_note_unique (order_id, source_note_id),
KEY order_notes_order_idx (order_id),
CONSTRAINT order_notes_order_fk
FOREIGN KEY (order_id) REFERENCES orders(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS order_status_history (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_id BIGINT UNSIGNED NOT NULL,
from_status_id VARCHAR(64) NULL,
to_status_id VARCHAR(64) NOT NULL,
changed_at DATETIME NOT NULL,
change_source ENUM('import', 'manual', 'api', 'sync') NOT NULL DEFAULT 'import',
comment VARCHAR(255) NULL,
payload_json JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY order_status_history_order_changed_idx (order_id, changed_at),
KEY order_status_history_to_status_idx (to_status_id),
CONSTRAINT order_status_history_order_fk
FOREIGN KEY (order_id) REFERENCES orders(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS order_tags_dict (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
integration_id INT UNSIGNED NULL,
source_tag_id VARCHAR(64) NOT NULL,
tag_name VARCHAR(128) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
updated_at_external DATETIME NULL,
payload_json JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY order_tags_dict_integration_source_tag_unique (integration_id, source_tag_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS order_tag_links (
order_id BIGINT UNSIGNED NOT NULL,
tag_dict_id BIGINT UNSIGNED NOT NULL,
assigned_at DATETIME NULL,
payload_json JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (order_id, tag_dict_id),
KEY order_tag_links_tag_idx (tag_dict_id),
CONSTRAINT order_tag_links_order_fk
FOREIGN KEY (order_id) REFERENCES orders(id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT order_tag_links_tag_fk
FOREIGN KEY (tag_dict_id) REFERENCES order_tags_dict(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS integration_order_sync_state (
integration_id INT UNSIGNED NOT NULL PRIMARY KEY,
last_synced_order_updated_at DATETIME NULL,
last_synced_source_order_id VARCHAR(64) NULL,
last_synced_external_order_id VARCHAR(128) NULL,
last_run_at DATETIME NULL,
last_success_at DATETIME NULL,
last_error VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,3 @@
ALTER TABLE integrations
ADD COLUMN orders_fetch_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER is_active,
ADD COLUMN orders_fetch_start_date DATE NULL AFTER orders_fetch_enabled;

View File

@@ -0,0 +1,88 @@
CREATE TABLE IF NOT EXISTS orders (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
integration_id INT UNSIGNED NOT NULL,
external_order_id VARCHAR(64) NOT NULL,
external_order_number VARCHAR(128) NULL,
status VARCHAR(64) NULL,
currency CHAR(3) NULL,
total_gross DECIMAL(12,2) NULL,
total_net DECIMAL(12,2) NULL,
buyer_email VARCHAR(190) NULL,
buyer_name VARCHAR(190) NULL,
buyer_phone VARCHAR(64) NULL,
payment_method VARCHAR(128) NULL,
payment_status VARCHAR(64) NULL,
delivery_method VARCHAR(128) NULL,
delivery_price DECIMAL(12,2) NULL,
delivery_tracking_number VARCHAR(128) NULL,
notes TEXT NULL,
external_created_at DATETIME NULL,
external_updated_at DATETIME NULL,
payload_json JSON NULL,
fetched_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY orders_integration_external_unique (integration_id, external_order_id),
KEY orders_integration_updated_idx (integration_id, external_updated_at),
KEY orders_status_idx (status),
KEY orders_fetched_at_idx (fetched_at),
CONSTRAINT orders_integration_fk
FOREIGN KEY (integration_id) REFERENCES integrations(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS order_items (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_id INT UNSIGNED NOT NULL,
external_item_id VARCHAR(64) NULL,
name VARCHAR(255) NOT NULL,
sku VARCHAR(128) NULL,
ean VARCHAR(64) NULL,
quantity DECIMAL(12,3) NOT NULL DEFAULT 0,
price_gross DECIMAL(12,2) NULL,
price_net DECIMAL(12,2) NULL,
vat DECIMAL(6,2) NULL,
payload_json JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY order_items_order_idx (order_id),
KEY order_items_external_item_idx (external_item_id),
CONSTRAINT order_items_order_fk
FOREIGN KEY (order_id) REFERENCES orders(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS integration_order_sync_state (
integration_id INT UNSIGNED PRIMARY KEY,
last_synced_external_updated_at DATETIME NULL,
last_synced_external_order_id VARCHAR(64) NULL,
last_run_at DATETIME NULL,
last_error VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT integration_order_sync_state_integration_fk
FOREIGN KEY (integration_id) REFERENCES integrations(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO cron_schedules (
job_type, interval_seconds, priority, max_attempts, payload, enabled, last_run_at, next_run_at, created_at, updated_at
) VALUES (
'shoppro_orders_import',
60,
90,
3,
NULL,
1,
NULL,
NOW(),
NOW(),
NOW()
)
ON DUPLICATE KEY UPDATE
interval_seconds = VALUES(interval_seconds),
priority = VALUES(priority),
max_attempts = VALUES(max_attempts),
payload = VALUES(payload),
enabled = VALUES(enabled),
updated_at = VALUES(updated_at);

View File

@@ -0,0 +1,7 @@
ALTER TABLE orders
ADD COLUMN internal_order_number VARCHAR(11) NULL AFTER id,
ADD UNIQUE KEY orders_internal_order_number_unique (internal_order_number);
UPDATE orders
SET internal_order_number = CONCAT('OP', LPAD(id, 9, '0'))
WHERE internal_order_number IS NULL OR internal_order_number = '';

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS order_status_mappings (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
integration_id INT UNSIGNED NOT NULL,
shoppro_status_code VARCHAR(64) NOT NULL,
shoppro_status_name VARCHAR(128) NULL,
orderpro_status_code VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY order_status_mappings_integration_shoppro_unique (integration_id, shoppro_status_code),
KEY order_status_mappings_integration_idx (integration_id),
KEY order_status_mappings_orderpro_idx (orderpro_status_code),
CONSTRAINT order_status_mappings_integration_fk
FOREIGN KEY (integration_id) REFERENCES integrations(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,40 @@
ALTER TABLE integrations
ADD COLUMN order_status_sync_direction VARCHAR(32) NOT NULL DEFAULT 'shoppro_to_orderpro' AFTER orders_fetch_start_date;
CREATE TABLE IF NOT EXISTS integration_order_status_sync_state (
integration_id INT UNSIGNED NOT NULL,
direction VARCHAR(32) NOT NULL,
last_synced_at DATETIME NULL,
last_synced_order_ref VARCHAR(64) NULL,
last_run_at DATETIME NULL,
last_error VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (integration_id, direction),
KEY integration_order_status_sync_state_direction_idx (direction),
CONSTRAINT integration_order_status_sync_state_integration_fk
FOREIGN KEY (integration_id) REFERENCES integrations(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO cron_schedules (
job_type, interval_seconds, priority, max_attempts, payload, enabled, last_run_at, next_run_at, created_at, updated_at
) VALUES (
'shoppro_order_status_sync',
3600,
95,
3,
NULL,
1,
NULL,
NOW(),
NOW(),
NOW()
)
ON DUPLICATE KEY UPDATE
interval_seconds = VALUES(interval_seconds),
priority = VALUES(priority),
max_attempts = VALUES(max_attempts),
payload = VALUES(payload),
enabled = VALUES(enabled),
updated_at = VALUES(updated_at);

View File

@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS order_status_groups (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(120) NOT NULL,
code VARCHAR(64) NOT NULL,
color_hex CHAR(7) NOT NULL DEFAULT '#64748b',
sort_order INT NOT NULL DEFAULT 0,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY order_status_groups_code_unique (code),
KEY order_status_groups_sort_order_idx (sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS order_statuses (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
group_id INT UNSIGNED NOT NULL,
name VARCHAR(120) NOT NULL,
code VARCHAR(64) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY order_statuses_code_unique (code),
KEY order_statuses_group_sort_idx (group_id, sort_order, id),
CONSTRAINT order_statuses_group_fk
FOREIGN KEY (group_id) REFERENCES order_status_groups(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

21
phpunit.xml Normal file
View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
bootstrap="tests/bootstrap.php"
cacheDirectory="storage/cache/phpunit"
colors="true"
executionOrder="depends,defects"
failOnWarning="true"
failOnRisky="true"
>
<testsuites>
<testsuite name="orderPRO">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>

File diff suppressed because one or more lines are too long

View File

@@ -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:38px;padding:8px 16px;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--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:38px;border:1px solid var(--c-border);border-radius:8px;padding:7px 12px;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)}.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:7px}.field-label{color:var(--c-text-strong);font-size:13px;font-weight:600}.table-wrap{width:100%;overflow-x:auto}.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}.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}:root{--shadow-card: 0 20px 50px rgba(22, 34, 58, 0.14)}*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;font-family:"Roboto","Segoe UI",sans-serif;color:var(--c-text);background:var(--c-bg);overflow-x:hidden}.bg-orb{position:fixed;width:460px;height:460px;border-radius:999px;filter:blur(28px);z-index:0;opacity:.45;pointer-events:none}.bg-orb-left{top:-200px;left:-180px;background:radial-gradient(circle, rgba(102, 144, 244, 0.48) 0%, rgba(102, 144, 244, 0) 70%)}.bg-orb-right{right:-200px;bottom:-220px;background:radial-gradient(circle, rgba(30, 42, 58, 0.36) 0%, rgba(30, 42, 58, 0) 70%)}.login-page{min-height:100vh;display:grid;place-items:center;padding:32px 20px;position:relative;z-index:1}.login-card{width:100%;max-width:430px;background:var(--c-surface);border:1px solid var(--c-border);border-radius:12px;box-shadow:var(--shadow-card);padding:34px 30px 28px;animation:card-enter 420ms ease-out}.login-header{margin-bottom:24px}.login-badge{display:inline-block;margin:0 0 14px;padding:5px 12px;border-radius:999px;border:1px solid #d9e2ff;background:#eef2ff;color:#3f5faf;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.06em}h1{margin:0;color:var(--c-text-strong);font-size:clamp(1.6rem,2.5vw,1.9rem);line-height:1.15;font-weight:700}.login-subtitle{margin:10px 0 0;font-size:15px;line-height:1.55;color:var(--c-muted)}.login-alert{margin-bottom:18px}.login-alert-placeholder{opacity:.56}.login-form{display:grid;gap:16px}.form-field{display:grid;gap:7px}.field-label{color:var(--c-text-strong);font-size:13px;font-weight:600}.login-form .form-control{min-height:46px;padding:0 14px;border-width:2px}.login-form .form-control::placeholder{color:#cbd5e0}.login-submit{margin-top:2px;font-size:15px;min-height:48px}@keyframes card-enter{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@media(max-width: 640px){.login-page{padding:18px 14px}.login-card{padding:24px 20px 20px}h1{font-size:1.55rem}}
: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--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)}.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{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}.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}:root{--shadow-card: 0 20px 50px rgba(22, 34, 58, 0.14)}*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;font-family:"Roboto","Segoe UI",sans-serif;color:var(--c-text);background:var(--c-bg);overflow-x:hidden}.bg-orb{position:fixed;width:460px;height:460px;border-radius:999px;filter:blur(28px);z-index:0;opacity:.45;pointer-events:none}.bg-orb-left{top:-200px;left:-180px;background:radial-gradient(circle, rgba(102, 144, 244, 0.48) 0%, rgba(102, 144, 244, 0) 70%)}.bg-orb-right{right:-200px;bottom:-220px;background:radial-gradient(circle, rgba(30, 42, 58, 0.36) 0%, rgba(30, 42, 58, 0) 70%)}.login-page{min-height:100vh;display:grid;place-items:center;padding:32px 20px;position:relative;z-index:1}.login-card{width:100%;max-width:430px;background:var(--c-surface);border:1px solid var(--c-border);border-radius:12px;box-shadow:var(--shadow-card);padding:34px 30px 28px;animation:card-enter 420ms ease-out}.login-header{margin-bottom:24px}.login-badge{display:inline-block;margin:0 0 14px;padding:5px 12px;border-radius:999px;border:1px solid #d9e2ff;background:#eef2ff;color:#3f5faf;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.06em}h1{margin:0;color:var(--c-text-strong);font-size:clamp(1.6rem,2.5vw,1.9rem);line-height:1.15;font-weight:700}.login-subtitle{margin:10px 0 0;font-size:15px;line-height:1.55;color:var(--c-muted)}.login-alert{margin-bottom:18px}.login-alert-placeholder{opacity:.56}.login-form{display:grid;gap:16px}.form-field{display:grid;gap:7px}.field-label{color:var(--c-text-strong);font-size:13px;font-weight:600}.login-form .form-control{min-height:46px;padding:0 14px;border-width:2px}.login-form .form-control::placeholder{color:#cbd5e0}.login-submit{margin-top:2px;font-size:15px;min-height:48px}@keyframes card-enter{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@media(max-width: 640px){.login-page{padding:18px 14px}.login-card{padding:24px 20px 20px}h1{font-size:1.55rem}}

View File

@@ -19,11 +19,15 @@ return [
'navigation' => [
'main_menu' => 'Menu glowne',
'users' => 'Uzytkownicy',
'database' => 'Baza danych',
'products' => 'Produkty',
'orders' => 'Zamowienia',
'orders_list' => 'Lista zamowien',
'marketplace' => 'Marketplace',
'cron' => 'Cron',
'dashboard' => 'Dashboard',
'settings' => 'Ustawienia',
'statuses' => 'Statusy',
],
'marketplace' => [
'title' => 'Marketplace',
@@ -90,6 +94,72 @@ return [
'description' => 'Szkielet panelu jest gotowy. Kolejny krok: lista zamowien.',
'active_user_label' => 'Aktywny uzytkownik:',
],
'orders' => [
'title' => 'Zamowienia',
'description' => 'Kompaktowa lista zamowien oparta o lokalna baze orderPRO.',
'empty' => 'Brak zamowien do wyswietlenia.',
'fields' => [
'order_ref' => 'Zamowienie',
'status' => 'Status',
'buyer' => 'Kupujacy',
'products' => 'Produkty',
'items' => 'Pozycje',
'totals' => 'Kwoty',
'shipping' => 'Wysylka',
'ordered_at' => 'Data zamowienia',
'source_updated_at' => 'Ostatnia zmiana',
],
'filters' => [
'search' => 'Szukaj (numer, ID, login, email, klient)',
'source' => 'Zrodlo',
'status' => 'Status',
'payment_status' => 'Platnosc',
'date_from' => 'Data od',
'date_to' => 'Data do',
'any' => 'Wszystkie',
],
'stats' => [
'all' => 'Wszystkie',
'paid' => 'Oplacone',
'shipped' => 'Wyslane',
],
'details' => [
'title' => 'Szczegoly zamowienia',
'tabs' => [
'details' => 'Szczegoly zamowienia',
'history' => 'Historia zmian',
'shipments' => 'Przesylki',
'payments' => 'Platnosci',
'documents' => 'Dokumenty powiazane',
],
'items_title' => 'Pozycje',
'item_name' => 'Nazwa',
'item_qty' => 'Ilosc',
'item_price' => 'Cena brutto',
'item_sum' => 'Suma',
'order_info' => 'Szczegoly zamowienia',
'payment_shipping' => 'Platnosc i wysylka',
'address_customer' => 'Dane zamawiajacego',
'address_invoice' => 'Dane do faktury',
'address_delivery' => 'Dane wysylki',
'notes_title' => 'Wiadomosci i zalaczniki',
'history_title' => 'Historia statusow',
'fields' => [
'status' => 'Status',
'source_order_id' => 'Numer zamowienia',
'external_order_id' => 'Numer zewnetrzny',
'ordered_at' => 'Data zamowienia',
'customer_login' => 'Login uzytkownika',
'currency' => 'Waluta',
'payment_status' => 'Status platnosci',
'total_with_tax' => 'Kwota brutto',
'total_paid' => 'Kwota oplacona',
'carrier' => 'Dostawa',
'send_date' => 'Data wysylki',
'shipments_count' => 'Liczba przesylek',
],
],
],
'users' => [
'title' => 'Zarzadzanie uzytkownikami',
'description' => 'Dodawaj konta dostepowe dla zespolu i zarzadzaj dostepem do panelu.',
@@ -362,7 +432,7 @@ return [
'description' => 'Konfiguracja i narzedzia administracyjne systemu.',
'submenu_label' => 'Sekcje ustawien',
'database' => [
'title' => 'Aktualizacja bazy danych',
'title' => 'Baza danych',
'state' => [
'needs_update' => 'Wykryto oczekujace migracje. Wymagana aktualizacja bazy.',
'up_to_date' => 'Baza danych jest aktualna.',
@@ -386,6 +456,82 @@ return [
'failed' => 'Nie udalo sie wykonac migracji. Sprawdz log i polaczenie bazy.',
],
],
'statuses' => [
'title' => 'Statusy',
'description' => 'Zarzadzaj grupami statusow i statusami wewnatrz grup. Kolor jest ustawiany na poziomie grupy.',
'tabs' => [
'label' => 'Zakladki statusow',
'statuses' => 'Statusy',
'groups' => 'Grupy statusow',
],
'fields' => [
'group' => 'Grupa',
'group_placeholder' => '-- wybierz grupe --',
'name' => 'Nazwa',
'code' => 'Kod',
'color' => 'Kolor grupy',
'is_active' => 'Aktywny',
'actions' => 'Akcje',
],
'hints' => [
'code_auto' => 'Kod techniczny generuje sie automatycznie z nazwy przy tworzeniu i nie jest edytowalny.',
'drag_statuses' => 'Przeciagnij i upusc, aby ustawic kolejnosc statusow.',
'drag_groups' => 'Przeciagnij i upusc, aby ustawic kolejnosc grup statusow.',
'auto_save_order' => 'Kolejnosc zapisuje sie automatycznie po upuszczeniu.',
'drag_handle' => 'Przeciagnij, aby zmienic kolejnosc',
],
'actions' => [
'add_group' => 'Dodaj grupe',
'add_status' => 'Dodaj status',
'save' => 'Zapisz',
'delete' => 'Usun',
],
'groups' => [
'create_title' => 'Nowa grupa statusow',
'list_title' => 'Grupy statusow',
'empty' => 'Brak zdefiniowanych grup statusow.',
],
'statuses' => [
'create_title' => 'Nowy status',
'list_title' => 'Statusy',
'empty' => 'Brak zdefiniowanych statusow.',
],
'confirm' => [
'title' => 'Potwierdzenie',
'confirm' => 'Usun',
'cancel' => 'Anuluj',
'delete_group' => 'Czy na pewno usunac grupe statusow? Usunie to rowniez statusy przypisane do tej grupy.',
'delete_status' => 'Czy na pewno usunac ten status?',
],
'flash' => [
'group_required' => 'Wybierz istniejaca grupe statusow.',
'group_not_found' => 'Nie znaleziono wskazanej grupy statusow.',
'group_name_invalid' => 'Nazwa grupy musi miec co najmniej 2 znaki.',
'group_code_invalid' => 'Kod grupy statusow jest niepoprawny.',
'group_code_taken' => 'Kod grupy statusow jest juz zajety.',
'group_created' => 'Grupa statusow zostala dodana.',
'group_updated' => 'Grupa statusow zostala zapisana.',
'group_deleted' => 'Grupa statusow zostala usunieta.',
'group_create_failed' => 'Nie udalo sie dodac grupy statusow.',
'group_update_failed' => 'Nie udalo sie zapisac grupy statusow.',
'group_delete_failed' => 'Nie udalo sie usunac grupy statusow.',
'status_not_found' => 'Nie znaleziono wskazanego statusu.',
'status_name_invalid' => 'Nazwa statusu musi miec co najmniej 2 znaki.',
'status_code_invalid' => 'Kod statusu jest niepoprawny.',
'status_code_taken' => 'Kod statusu jest juz zajety.',
'status_created' => 'Status zostal dodany.',
'status_updated' => 'Status zostal zapisany.',
'status_deleted' => 'Status zostal usuniety.',
'status_create_failed' => 'Nie udalo sie dodac statusu.',
'status_update_failed' => 'Nie udalo sie zapisac statusu.',
'status_delete_failed' => 'Nie udalo sie usunac statusu.',
'reorder_empty' => 'Nie przeslano kolejnosci do zapisu.',
'group_reordered' => 'Kolejnosc grup statusow zostala zapisana.',
'status_reordered' => 'Kolejnosc statusow zostala zapisana.',
'group_reorder_failed' => 'Nie udalo sie zapisac kolejnosci grup statusow.',
'status_reorder_failed' => 'Nie udalo sie zapisac kolejnosci statusow.',
],
],
'integrations' => [
'title' => 'Integracje shopPRO',
'list_title' => 'Integracje shopPRO',
@@ -397,6 +543,11 @@ return [
'base_url' => 'Base URL',
'api_key' => 'API Key',
'timeout_seconds' => 'Timeout (sekundy)',
'orders_fetch_start_date' => 'Data startu pobierania zamowien',
'orders_fetch_enabled_checkbox' => 'Pobieraj zamowienia',
'order_status_sync_direction' => 'Kierunek synchronizacji statusow',
'order_status_sync_direction_shoppro_to_orderpro' => 'shopPRO -> orderPRO',
'order_status_sync_direction_orderpro_to_shoppro' => 'orderPRO -> shopPRO',
'active' => 'Aktywna',
'active_checkbox' => 'Integracja aktywna',
'last_test' => 'Ostatni test',
@@ -434,6 +585,7 @@ return [
'validation' => [
'name_min' => 'Nazwa integracji musi miec co najmniej 2 znaki.',
'base_url_invalid' => 'Podaj poprawny adres URL (http lub https).',
'orders_fetch_start_date_invalid' => 'Podaj poprawna date startu pobierania zamowien (RRRR-MM-DD).',
'api_key_required' => 'Podaj klucz API dla integracji.',
'name_taken' => 'Integracja o tej nazwie juz istnieje.',
],
@@ -458,6 +610,40 @@ return [
'action' => 'Importuj 1 produkt',
],
],
'order_statuses' => [
'title' => 'Statusy zamowien',
'description' => 'Mapowanie statusow zamowien pomiedzy orderPRO i shopPRO.',
'integration' => 'Integracja shopPRO',
'no_integrations' => 'Brak aktywnych integracji shopPRO z kluczem API.',
'empty' => 'Brak statusow shopPRO do zmapowania.',
'fields' => [
'shoppro_code' => 'Kod statusu shopPRO',
'shoppro_name' => 'Nazwa statusu shopPRO',
'orderpro_status' => 'Status orderPRO',
'no_mapping' => '-- brak mapowania --',
],
'actions' => [
'save' => 'Zapisz mapowanie statusow',
],
'orderpro' => [
'new' => 'Nowe',
'confirmed' => 'Potwierdzone',
'processing' => 'W realizacji',
'ready_to_ship' => 'Gotowe do wysylki',
'shipped' => 'Wyslane',
'delivered' => 'Dostarczone',
'cancelled' => 'Anulowane',
'returned' => 'Zwrocone',
'on_hold' => 'Wstrzymane',
],
'flash' => [
'integration_required' => 'Wybierz integracje do mapowania statusow.',
'credentials_missing' => 'Wybrana integracja nie ma poprawnych danych API do pobrania statusow.',
'load_failed' => 'Nie udalo sie pobrac statusow shopPRO.',
'saved' => 'Mapowanie statusow zostalo zapisane.',
'save_failed' => 'Nie udalo sie zapisac mapowania statusow.',
],
],
'cron' => [
'title' => 'Cron',
'run_on_web_title' => 'Uruchamianie crona podczas nawigacji',

View File

@@ -12,7 +12,7 @@ body {
body {
margin: 0;
font-family: "Roboto", "Segoe UI", sans-serif;
font-size: 14px;
font-size: 13px;
color: var(--c-text);
background: var(--c-bg);
}
@@ -127,7 +127,7 @@ a {
}
.topbar {
height: 56px;
height: 50px;
border-bottom: 1px solid var(--c-border);
background: var(--c-surface);
display: flex;
@@ -152,16 +152,16 @@ a {
.container {
max-width: none;
width: calc(100% - 28px);
margin: 18px 14px;
padding: 0 6px 24px;
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: 24px;
padding: 14px;
}
.card h1 {
@@ -189,16 +189,16 @@ a {
.section-title {
margin: 0;
color: var(--c-text-strong);
font-size: 20px;
font-size: 18px;
font-weight: 700;
}
.mt-12 {
margin-top: 12px;
margin-top: 8px;
}
.mt-16 {
margin-top: 16px;
margin-top: 12px;
}
.settings-grid {
@@ -298,11 +298,169 @@ a {
flex-wrap: wrap;
}
.statuses-form {
display: grid;
gap: 8px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.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, 0.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: 0.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: 6px;
margin-top: 2px;
}
.modal-backdrop {
@@ -575,6 +733,592 @@ a {
width: 16px;
height: 16px;
}
.orders-page {
.orders-head {
background: linear-gradient(120deg, #f8fbff 0%, #eef5ff 100%);
border: 1px solid #dbe7fb;
}
.table-list {
border: 1px solid #dde5f2;
border-radius: 12px;
box-shadow: 0 6px 16px rgba(20, 44, 86, 0.08);
}
.table-list__header {
padding: 10px 6px 2px;
}
.table-list-filters {
padding: 6px 6px 2px;
border-top: 1px solid #ebf0f7;
border-bottom: 1px solid #ebf0f7;
background: #f9fbff;
}
.table-wrap {
border-radius: 10px;
overflow: hidden;
border: 1px solid #e7edf6;
}
.table thead th {
background: #f3f7fd;
color: #30435f;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.table tbody td {
vertical-align: middle;
padding-top: 10px;
padding-bottom: 10px;
border-bottom-color: #edf2f8;
}
.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;
&__label {
display: block;
color: #5f6f83;
font-size: 11px;
margin-bottom: 2px;
}
&__value {
color: #12233a;
font-size: 16px;
font-weight: 700;
}
}
.orders-ref {
display: grid;
gap: 2px;
min-width: 170px;
&__main {
font-weight: 700;
color: #0f1f35;
font-size: 14px;
}
&__meta {
display: inline-flex;
flex-wrap: wrap;
gap: 4px 10px;
color: #64748b;
font-size: 12px;
}
}
.orders-buyer {
display: grid;
gap: 2px;
&__name {
color: #0f172a;
font-weight: 600;
font-size: 14px;
}
&__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;
&.is-info {
border-color: #bfdbfe;
background: #eff6ff;
color: #1d4ed8;
}
&.is-success {
border-color: #bbf7d0;
background: #f0fdf4;
color: #166534;
}
&.is-danger {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
&.is-warn {
border-color: #fde68a;
background: #fffbeb;
color: #92400e;
}
}
.orders-mini {
font-size: 14px;
color: #223247;
line-height: 1.25;
}
.orders-products {
display: grid;
gap: 4px;
min-width: 240px;
&__meta,
&__more {
font-size: 12px;
color: #64748b;
}
}
.orders-product {
display: grid;
grid-template-columns: 48px 1fr;
gap: 6px;
align-items: center;
&__thumb {
width: 48px;
height: 48px;
border-radius: 4px;
border: 1px solid #dbe3ef;
object-fit: cover;
background: #fff;
}
&__thumb--empty {
display: inline-block;
background: #eef2f7;
border-style: dashed;
}
&__txt {
min-width: 0;
display: grid;
gap: 1px;
}
&__name {
font-size: 14px;
color: #0f172a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__qty {
font-size: 12px;
color: #64748b;
}
}
.orders-image-trigger {
border: 0;
padding: 0;
margin: 0;
background: transparent;
cursor: zoom-in;
display: inline-flex;
align-items: center;
justify-content: center;
}
.orders-money {
display: grid;
gap: 2px;
&__main {
color: #0f172a;
font-weight: 700;
font-size: 14px;
}
&__meta {
color: #64748b;
font-size: 12px;
}
}
.table-list[data-table-list-id="orders"] {
gap: 8px;
.table-list__header {
padding: 2px 0 0;
}
.table-list-filters {
gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.table th,
.table td {
padding: 6px 8px;
}
.table thead th {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.02em;
white-space: nowrap;
}
.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;
&__title {
font-size: 13px;
font-weight: 700;
color: #0f172a;
margin-bottom: 8px;
}
}
.order-status-group {
margin-bottom: 10px;
&__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;
&__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: #ffffff;
}
}
.order-status-row:hover {
background: #f1f5f9;
}
.order-status-row.is-active {
background: rgba(15, 23, 42, 0.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-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;
}
.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;
&.is-info {
border-color: #bfdbfe;
background: #eff6ff;
color: #1d4ed8;
}
&.is-success {
border-color: #bbf7d0;
background: #f0fdf4;
color: #166534;
}
&.is-danger {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
&.is-warn {
border-color: #fde68a;
background: #fffbeb;
color: #92400e;
}
&.is-empty {
color: #94a3b8;
}
}
.order-buyer {
display: grid;
gap: 2px;
&__name {
color: #0f172a;
font-weight: 600;
}
&__email {
color: #64748b;
font-size: 12px;
}
}
.table-inline-action {
display: inline-block;
margin-right: 6px;
@@ -934,7 +1678,7 @@ a {
width: calc(100% - 16px);
margin-left: 8px;
margin-right: 8px;
padding: 0 4px 18px;
padding: 0 3px 12px;
}
.settings-grid {
@@ -946,14 +1690,52 @@ a {
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,
.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;
}
@@ -968,7 +1750,7 @@ a {
}
.card {
padding: 18px;
padding: 12px;
}
.modal--image-preview {

View File

@@ -16,8 +16,8 @@
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
padding: 8px 16px;
min-height: 34px;
padding: 6px 12px;
border: 1px solid transparent;
border-radius: 8px;
font: inherit;
@@ -74,10 +74,10 @@
.form-control {
width: 100%;
min-height: 38px;
min-height: 34px;
border: 1px solid var(--c-border);
border-radius: 8px;
padding: 7px 12px;
padding: 5px 10px;
font: inherit;
color: var(--c-text-strong);
background: #ffffff;
@@ -118,7 +118,7 @@
.form-field {
display: grid;
gap: 7px;
gap: 5px;
}
.field-label {

View File

@@ -0,0 +1,36 @@
<?php
$panelItems = is_array($statusPanelList ?? null) ? $statusPanelList : [];
$panelTitle = trim((string) ($statusPanelTitle ?? 'Statusy'));
?>
<aside class="card order-statuses-side">
<div class="order-statuses-side__title"><?= $e($panelTitle) ?></div>
<?php foreach ($panelItems as $group): ?>
<?php $groupItems = is_array($group['items'] ?? null) ? $group['items'] : []; ?>
<div class="order-status-group">
<?php if ((string) ($group['name'] ?? '') !== ''): ?>
<div class="order-status-group__name"><?= $e((string) ($group['name'] ?? '')) ?></div>
<?php endif; ?>
<?php foreach ($groupItems as $item): ?>
<?php
$tone = (string) ($item['tone'] ?? 'neutral');
$color = (string) ($item['color_hex'] ?? '#64748b');
$rowClass = 'order-status-row tone-' . $tone . (!empty($item['is_active']) ? ' is-active' : '');
$url = trim((string) ($item['url'] ?? ''));
?>
<?php if ($url !== ''): ?>
<a href="<?= $e($url) ?>" class="<?= $e($rowClass) ?>" style="--status-color: <?= $e($color) ?>;">
<span class="order-status-row__label"><?= $e((string) ($item['label'] ?? '')) ?></span>
<span class="order-status-row__count"><?= $e((string) ((int) ($item['count'] ?? 0))) ?></span>
</a>
<?php else: ?>
<div class="<?= $e($rowClass) ?>" style="--status-color: <?= $e($color) ?>;">
<span class="order-status-row__label"><?= $e((string) ($item['label'] ?? '')) ?></span>
<span class="order-status-row__count"><?= $e((string) ((int) ($item['count'] ?? 0))) ?></span>
</div>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</aside>

View File

@@ -12,43 +12,36 @@
</head>
<body>
<?php $currentMenu = (string) ($activeMenu ?? ''); ?>
<?php $marketplaceMenuIntegrations = is_array($marketplaceIntegrations ?? null) ? $marketplaceIntegrations : []; ?>
<?php $isMarketplaceMenu = $currentMenu === 'marketplace'; ?>
<?php $currentSettings = (string) ($activeSettings ?? ''); ?>
<?php $currentOrders = (string) ($activeOrders ?? ''); ?>
<div class="app-shell">
<aside class="sidebar">
<div class="sidebar__brand"><?= $e($t('brand.name_prefix')) ?><strong><?= $e($t('brand.name_suffix')) ?></strong></div>
<nav class="sidebar__nav" aria-label="<?= $e($t('navigation.main_menu')) ?>">
<a class="sidebar__link<?= $currentMenu === 'dashboard' ? ' is-active' : '' ?>" href="/dashboard">
<?= $e($t('navigation.dashboard')) ?>
</a>
<a class="sidebar__link<?= $currentMenu === 'users' ? ' is-active' : '' ?>" href="/users">
<?= $e($t('navigation.users')) ?>
</a>
<a class="sidebar__link<?= $currentMenu === 'products' ? ' is-active' : '' ?>" href="/products">
<?= $e($t('navigation.products')) ?>
</a>
<details class="sidebar__group<?= $isMarketplaceMenu ? ' is-active' : '' ?>"<?= $isMarketplaceMenu ? ' open' : '' ?>>
<summary class="sidebar__group-toggle"><?= $e($t('navigation.marketplace')) ?></summary>
<details class="sidebar__group<?= $currentMenu === 'orders' ? ' is-active' : '' ?>"<?= $currentMenu === 'orders' ? ' open' : '' ?>>
<summary class="sidebar__group-toggle"><?= $e($t('navigation.orders')) ?></summary>
<div class="sidebar__group-links">
<a class="sidebar__sublink<?= $isMarketplaceMenu && (int) ($selectedMarketplaceIntegrationId ?? 0) === 0 ? ' is-active' : '' ?>" href="/marketplace">
<?= $e($t('marketplace.integrations_title')) ?>
<a class="sidebar__sublink<?= $currentMenu === 'orders' && $currentOrders === 'list' ? ' is-active' : '' ?>" href="/orders/list">
<?= $e($t('navigation.orders_list')) ?>
</a>
</div>
</details>
<details class="sidebar__group<?= $currentMenu === 'settings' ? ' is-active' : '' ?>"<?= $currentMenu === 'settings' ? ' open' : '' ?>>
<summary class="sidebar__group-toggle"><?= $e($t('navigation.settings')) ?></summary>
<div class="sidebar__group-links">
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'users' ? ' is-active' : '' ?>" href="/settings/users">
<?= $e($t('navigation.users')) ?>
</a>
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'database' ? ' is-active' : '' ?>" href="/settings/database">
<?= $e($t('navigation.database')) ?>
</a>
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'statuses' ? ' is-active' : '' ?>" href="/settings/statuses">
<?= $e($t('navigation.statuses')) ?>
</a>
<?php foreach ($marketplaceMenuIntegrations as $integration): ?>
<?php $integrationId = (int) ($integration['id'] ?? 0); ?>
<?php if ($integrationId <= 0) continue; ?>
<a class="sidebar__sublink<?= ($isMarketplaceMenu && (int) ($selectedMarketplaceIntegrationId ?? 0) === $integrationId) ? ' is-active' : '' ?>" href="/marketplace/<?= $e((string) $integrationId) ?>">
<?= $e((string) ($integration['name'] ?? ('#' . $integrationId))) ?>
</a>
<?php endforeach; ?>
</div>
</details>
<a class="sidebar__link<?= $currentMenu === 'cron' ? ' is-active' : '' ?>" href="/settings/cron">
<?= $e($t('navigation.cron')) ?>
</a>
<a class="sidebar__link<?= $currentMenu === 'settings' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro">
<?= $e($t('navigation.settings')) ?>
</a>
</nav>
</aside>

View File

@@ -0,0 +1,84 @@
<?php $statusPanelList = is_array($statusPanel ?? null) ? $statusPanel : []; ?>
<?php $statusPanelTitle = 'Statusy'; ?>
<section class="order-show-layout">
<?php require __DIR__ . '/../components/order-status-panel.php'; ?>
<div class="order-show-main">
<section class="card orders-list-page">
<div class="orders-head">
<div>
<h2 class="section-title"><?= $e($t('orders.title')) ?></h2>
<p class="muted mt-12"><?= $e($t('orders.description')) ?></p>
</div>
<div class="orders-stats">
<div class="orders-stat">
<span class="orders-stat__label"><?= $e($t('orders.stats.all')) ?></span>
<strong class="orders-stat__value"><?= $e((string) ((int) ($stats['all'] ?? 0))) ?></strong>
</div>
<div class="orders-stat">
<span class="orders-stat__label"><?= $e($t('orders.stats.paid')) ?></span>
<strong class="orders-stat__value"><?= $e((string) ((int) ($stats['paid'] ?? 0))) ?></strong>
</div>
<div class="orders-stat">
<span class="orders-stat__label"><?= $e($t('orders.stats.shipped')) ?></span>
<strong class="orders-stat__value"><?= $e((string) ((int) ($stats['shipped'] ?? 0))) ?></strong>
</div>
</div>
</div>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--warning mt-12" role="alert">
<?= $e((string) $errorMessage) ?>
</div>
<?php endif; ?>
</section>
<?php require __DIR__ . '/../components/table-list.php'; ?>
</div>
</section>
<div class="modal-backdrop" data-orders-image-modal hidden>
<div class="modal modal--image-preview" role="dialog" aria-modal="true" aria-label="Podglad zdjecia produktu">
<div class="modal__header">
<h3>Podglad zdjecia</h3>
<button type="button" class="btn btn--secondary" data-orders-image-close>Zamknij</button>
</div>
<div class="modal__body">
<img src="" alt="" class="product-image-preview__img" data-orders-image-preview>
</div>
</div>
</div>
<script>
(function () {
var modal = document.querySelector('[data-orders-image-modal]');
var preview = document.querySelector('[data-orders-image-preview]');
if (!modal || !preview) return;
function closeModal() {
modal.setAttribute('hidden', 'hidden');
preview.setAttribute('src', '');
}
document.addEventListener('click', function (event) {
var trigger = event.target.closest('.js-order-img-open');
if (trigger) {
var imageUrl = trigger.getAttribute('data-image-url') || '';
if (imageUrl === '') return;
preview.setAttribute('src', imageUrl);
modal.removeAttribute('hidden');
return;
}
if (event.target.matches('[data-orders-image-close]') || event.target === modal) {
closeModal();
}
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && !modal.hasAttribute('hidden')) {
closeModal();
}
});
})();
</script>

View File

@@ -0,0 +1,249 @@
<?php
$orderRow = is_array($order ?? null) ? $order : [];
$itemsList = is_array($items ?? null) ? $items : [];
$addressesList = is_array($addresses ?? null) ? $addresses : [];
$paymentsList = is_array($payments ?? null) ? $payments : [];
$shipmentsList = is_array($shipments ?? null) ? $shipments : [];
$documentsList = is_array($documents ?? null) ? $documents : [];
$notesList = is_array($notes ?? null) ? $notes : [];
$historyList = is_array($history ?? null) ? $history : [];
$statusPanelList = is_array($statusPanel ?? null) ? $statusPanel : [];
$statusPanelTitle = 'Statusy';
$addressByType = [
'customer' => null,
'invoice' => null,
'delivery' => null,
];
foreach ($addressesList as $address) {
$type = (string) ($address['address_type'] ?? '');
if ($type !== '' && array_key_exists($type, $addressByType) && $addressByType[$type] === null) {
$addressByType[$type] = $address;
}
}
?>
<section class="order-show-layout">
<?php require __DIR__ . '/../components/order-status-panel.php'; ?>
<div class="order-show-main">
<section class="card order-details-page">
<div class="order-details-head">
<div>
<a href="/orders/list" class="order-back-link">&larr; <?= $e($t('navigation.orders_list')) ?></a>
<h2 class="section-title mt-12"><?= $e($t('orders.details.title')) ?> #<?= $e((string) ($orderId ?? 0)) ?></h2>
<div class="order-details-sub mt-12">
<span><?= $e((string) ($orderRow['source_order_id'] ?? '')) ?></span>
<span><?= $e((string) ($orderRow['external_order_id'] ?? '')) ?></span>
</div>
</div>
<div class="order-details-actions">
<button type="button" class="btn btn--secondary">Strefa klienta</button>
<button type="button" class="btn btn--secondary">Przygotuj przesylke</button>
<button type="button" class="btn btn--secondary">Platnosc</button>
<button type="button" class="btn btn--secondary">Drukuj</button>
<button type="button" class="btn btn--primary">Pakuj</button>
<button type="button" class="btn btn--secondary">Edytuj</button>
</div>
</div>
<div class="order-details-pill mt-12"><?= $e((string) ($statusLabel ?? '-')) ?></div>
</section>
<section class="card mt-16 order-details-tabs">
<button type="button" class="order-details-tab is-active" data-order-tab-target="details"><?= $e($t('orders.details.tabs.details')) ?></button>
<button type="button" class="order-details-tab" data-order-tab-target="history"><?= $e($t('orders.details.tabs.history')) ?> (<?= $e((string) count($historyList)) ?>)</button>
<button type="button" class="order-details-tab" data-order-tab-target="shipments"><?= $e($t('orders.details.tabs.shipments')) ?> (<?= $e((string) count($shipmentsList)) ?>)</button>
<button type="button" class="order-details-tab" data-order-tab-target="payments"><?= $e($t('orders.details.tabs.payments')) ?> (<?= $e((string) count($paymentsList)) ?>)</button>
<button type="button" class="order-details-tab" data-order-tab-target="documents"><?= $e($t('orders.details.tabs.documents')) ?> (<?= $e((string) count($documentsList)) ?>)</button>
</section>
<div class="order-tab-panel is-active" data-order-tab-panel="details">
<section class="card mt-16">
<h3 class="section-title"><?= $e($t('orders.details.items_title')) ?> (<?= $e((string) count($itemsList)) ?>)</h3>
<div class="table-wrap mt-12">
<table class="table table--details">
<thead>
<tr>
<th>Lp.</th>
<th><?= $e($t('orders.details.item_name')) ?></th>
<th>SKU/EAN</th>
<th><?= $e($t('orders.details.item_qty')) ?></th>
<th><?= $e($t('orders.details.item_price')) ?></th>
<th><?= $e($t('orders.details.item_sum')) ?></th>
</tr>
</thead>
<tbody>
<?php if ($itemsList === []): ?>
<tr><td colspan="6" class="muted"><?= $e($t('orders.empty')) ?></td></tr>
<?php endif; ?>
<?php foreach ($itemsList as $idx => $item): ?>
<?php
$qty = (float) ($item['quantity'] ?? 0);
$price = $item['original_price_with_tax'] !== null ? (float) $item['original_price_with_tax'] : null;
$sum = $price === null ? null : ($qty * $price);
$media = trim((string) ($item['media_url'] ?? ''));
?>
<tr>
<td><?= $e((string) ($idx + 1)) ?></td>
<td>
<div class="order-item-cell">
<?php if ($media !== ''): ?>
<img src="<?= $e($media) ?>" alt="" class="order-item-thumb">
<?php else: ?>
<span class="order-item-thumb order-item-thumb--empty"></span>
<?php endif; ?>
<div>
<div class="order-item-name"><?= $e((string) ($item['original_name'] ?? '')) ?></div>
<div class="muted"><?= $e((string) ($item['item_type'] ?? '')) ?></div>
</div>
</div>
</td>
<td>
<div><?= $e((string) ($item['sku'] ?? '-')) ?></div>
<div class="muted"><?= $e((string) ($item['ean'] ?? '-')) ?></div>
</td>
<td><?= $e((string) $qty) ?></td>
<td><?= $e($price !== null ? number_format($price, 2, '.', ' ') : '-') ?></td>
<td><?= $e($sum !== null ? number_format($sum, 2, '.', ' ') : '-') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
<section class="mt-16 order-grid-2">
<article class="card">
<h3 class="section-title"><?= $e($t('orders.details.order_info')) ?></h3>
<dl class="order-kv mt-12">
<dt><?= $e($t('orders.details.fields.status')) ?></dt><dd><?= $e((string) ($statusLabel ?? '-')) ?></dd>
<dt><?= $e($t('orders.details.fields.source_order_id')) ?></dt><dd><?= $e((string) ($orderRow['source_order_id'] ?? '-')) ?></dd>
<dt><?= $e($t('orders.details.fields.external_order_id')) ?></dt><dd><?= $e((string) ($orderRow['external_order_id'] ?? '-')) ?></dd>
<dt><?= $e($t('orders.details.fields.ordered_at')) ?></dt><dd><?= $e((string) ($orderRow['ordered_at'] ?? '-')) ?></dd>
<dt><?= $e($t('orders.details.fields.customer_login')) ?></dt><dd><?= $e((string) ($orderRow['customer_login'] ?? '-')) ?></dd>
<dt><?= $e($t('orders.details.fields.currency')) ?></dt><dd><?= $e((string) ($orderRow['currency'] ?? '-')) ?></dd>
</dl>
</article>
<article class="card">
<h3 class="section-title"><?= $e($t('orders.details.payment_shipping')) ?></h3>
<dl class="order-kv mt-12">
<dt><?= $e($t('orders.details.fields.payment_status')) ?></dt><dd><?= $e((string) ($orderRow['payment_status'] ?? '-')) ?></dd>
<dt><?= $e($t('orders.details.fields.total_with_tax')) ?></dt><dd><?= $e((string) ($orderRow['total_with_tax'] ?? '-')) ?></dd>
<dt><?= $e($t('orders.details.fields.total_paid')) ?></dt><dd><?= $e((string) ($orderRow['total_paid'] ?? '-')) ?></dd>
<dt><?= $e($t('orders.details.fields.carrier')) ?></dt><dd><?= $e((string) ($orderRow['external_carrier_id'] ?? '-')) ?></dd>
<dt><?= $e($t('orders.details.fields.send_date')) ?></dt><dd><?= $e((string) ($orderRow['send_date_max'] ?? '-')) ?></dd>
<dt><?= $e($t('orders.details.fields.shipments_count')) ?></dt><dd><?= $e((string) count($shipmentsList)) ?></dd>
</dl>
</article>
</section>
<section class="mt-16 order-grid-3">
<?php foreach (['customer' => $t('orders.details.address_customer'), 'invoice' => $t('orders.details.address_invoice'), 'delivery' => $t('orders.details.address_delivery')] as $addrType => $addrTitle): ?>
<?php $addr = is_array($addressByType[$addrType] ?? null) ? $addressByType[$addrType] : []; ?>
<article class="card">
<h3 class="section-title"><?= $e((string) $addrTitle) ?></h3>
<div class="order-address mt-12">
<?php if ($addr === []): ?>
<div class="muted">-</div>
<?php else: ?>
<div><?= $e((string) ($addr['name'] ?? '')) ?></div>
<div><?= $e((string) (($addr['street_name'] ?? '') . ' ' . ($addr['street_number'] ?? ''))) ?></div>
<div><?= $e((string) (($addr['zip_code'] ?? '') . ' ' . ($addr['city'] ?? ''))) ?></div>
<div><?= $e((string) ($addr['country'] ?? '')) ?></div>
<div><?= $e((string) ($addr['phone'] ?? '')) ?></div>
<div><?= $e((string) ($addr['email'] ?? '')) ?></div>
<?php endif; ?>
</div>
</article>
<?php endforeach; ?>
</section>
<section class="mt-16 order-grid-2">
<article class="card">
<h3 class="section-title"><?= $e($t('orders.details.notes_title')) ?></h3>
<div class="order-events mt-12">
<?php if ($notesList === []): ?>
<div class="muted">-</div>
<?php endif; ?>
<?php foreach ($notesList as $note): ?>
<div class="order-event">
<div class="order-event__head"><?= $e((string) ($note['note_type'] ?? '')) ?> | <?= $e((string) ($note['created_at_external'] ?? '')) ?></div>
<div class="order-event__body"><?= $e((string) ($note['comment'] ?? '')) ?></div>
</div>
<?php endforeach; ?>
</div>
</article>
<article class="card">
<h3 class="section-title"><?= $e($t('orders.details.history_title')) ?></h3>
<div class="order-events mt-12">
<?php if ($historyList === []): ?>
<div class="muted">-</div>
<?php endif; ?>
<?php foreach ($historyList as $event): ?>
<div class="order-event">
<div class="order-event__head"><?= $e((string) ($event['changed_at'] ?? '')) ?></div>
<div class="order-event__body"><?= $e((string) ($event['from_status_id'] ?? '-')) ?> -> <?= $e((string) ($event['to_status_id'] ?? '-')) ?></div>
</div>
<?php endforeach; ?>
</div>
</article>
</section>
</div>
<div class="order-tab-panel" data-order-tab-panel="history">
<section class="card mt-16">
<h3 class="section-title"><?= $e($t('orders.details.tabs.history')) ?></h3>
<div class="order-empty-placeholder mt-12"></div>
</section>
</div>
<div class="order-tab-panel" data-order-tab-panel="shipments">
<section class="card mt-16">
<h3 class="section-title"><?= $e($t('orders.details.tabs.shipments')) ?></h3>
<div class="order-empty-placeholder mt-12"></div>
</section>
</div>
<div class="order-tab-panel" data-order-tab-panel="payments">
<section class="card mt-16">
<h3 class="section-title"><?= $e($t('orders.details.tabs.payments')) ?></h3>
<div class="order-empty-placeholder mt-12"></div>
</section>
</div>
<div class="order-tab-panel" data-order-tab-panel="documents">
<section class="card mt-16">
<h3 class="section-title"><?= $e($t('orders.details.tabs.documents')) ?></h3>
<div class="order-empty-placeholder mt-12"></div>
</section>
</div>
</div>
</section>
<script>
(function () {
var tabButtons = document.querySelectorAll('[data-order-tab-target]');
var tabPanels = document.querySelectorAll('[data-order-tab-panel]');
if (!tabButtons.length || !tabPanels.length) return;
function setActiveTab(target) {
var key = target || 'details';
tabButtons.forEach(function (btn) {
btn.classList.toggle('is-active', btn.getAttribute('data-order-tab-target') === key);
});
tabPanels.forEach(function (panel) {
panel.classList.toggle('is-active', panel.getAttribute('data-order-tab-panel') === key);
});
}
tabButtons.forEach(function (button) {
button.addEventListener('click', function () {
setActiveTab(button.getAttribute('data-order-tab-target') || 'details');
});
});
setActiveTab('details');
})();
</script>

View File

@@ -8,18 +8,6 @@ $logs = (array) ($runLogs ?? []);
?>
<section class="card">
<h1><?= $e($t('settings.title')) ?></h1>
<p class="muted"><?= $e($t('settings.description')) ?></p>
<nav class="settings-nav mt-16" aria-label="<?= $e($t('settings.submenu_label')) ?>">
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'database' ? ' is-active' : '' ?>" href="/settings/database"><?= $e($t('settings.database.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'cron' ? ' is-active' : '' ?>" href="/settings/cron"><?= $e($t('settings.cron.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'gs1' ? ' is-active' : '' ?>" href="/settings/gs1"><?= $e($t('settings.gs1.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'products' ? ' is-active' : '' ?>" href="/settings/products"><?= $e($t('settings.products.title')) ?></a>
</nav>
</section>
<section class="card mt-16">
<h2 class="section-title"><?= $e($t('settings.database.title')) ?></h2>
<?php if (!empty($errorMessage)): ?>

View File

@@ -0,0 +1,341 @@
<?php
$groupsList = is_array($groups ?? null) ? $groups : [];
$statusesList = is_array($statuses ?? null) ? $statuses : [];
$statusesByGroup = [];
foreach ($groupsList as $groupRow) {
$groupId = max(0, (int) ($groupRow['id'] ?? 0));
if ($groupId <= 0) {
continue;
}
$statusesByGroup[$groupId] = [];
}
foreach ($statusesList as $statusRow) {
$groupId = max(0, (int) ($statusRow['group_id'] ?? 0));
if ($groupId <= 0) {
continue;
}
if (!isset($statusesByGroup[$groupId])) {
$statusesByGroup[$groupId] = [];
}
$statusesByGroup[$groupId][] = $statusRow;
}
?>
<section class="card">
<h2 class="section-title"><?= $e($t('settings.statuses.title')) ?></h2>
<p class="muted mt-12"><?= $e($t('settings.statuses.description')) ?></p>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<div class="alert alert--success mt-12" role="status"><?= $e((string) $successMessage) ?></div>
<?php endif; ?>
</section>
<section class="card mt-16">
<nav class="content-tabs-nav" aria-label="<?= $e($t('settings.statuses.tabs.label')) ?>">
<button type="button" class="content-tab-btn is-active" data-tab-target="statuses-tab">
<?= $e($t('settings.statuses.tabs.statuses')) ?>
</button>
<button type="button" class="content-tab-btn" data-tab-target="groups-tab">
<?= $e($t('settings.statuses.tabs.groups')) ?>
</button>
</nav>
<div class="content-tab-panel is-active" data-tab-panel="statuses-tab">
<h2 class="section-title"><?= $e($t('settings.statuses.statuses.create_title')) ?></h2>
<form class="statuses-form mt-16" action="/settings/statuses/create" method="post">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<label class="form-field">
<span class="field-label"><?= $e($t('settings.statuses.fields.group')) ?></span>
<select name="group_id" class="form-control" required>
<option value=""><?= $e($t('settings.statuses.fields.group_placeholder')) ?></option>
<?php foreach ($groupsList as $group): ?>
<?php $groupId = max(0, (int) ($group['id'] ?? 0)); ?>
<?php if ($groupId <= 0) continue; ?>
<option value="<?= $e((string) $groupId) ?>"><?= $e((string) ($group['name'] ?? ('#' . $groupId))) ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.statuses.fields.name')) ?></span>
<input type="text" name="name" class="form-control" maxlength="120" required>
</label>
<label class="field-inline">
<input type="hidden" name="is_active" value="0">
<input type="checkbox" name="is_active" value="1" checked>
<span><?= $e($t('settings.statuses.fields.is_active')) ?></span>
</label>
<p class="muted statuses-hint"><?= $e($t('settings.statuses.hints.code_auto')) ?></p>
<div class="form-actions">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.statuses.actions.add_status')) ?></button>
</div>
</form>
<h2 class="section-title mt-16"><?= $e($t('settings.statuses.statuses.list_title')) ?></h2>
<?php if ($groupsList === []): ?>
<div class="alert alert--warning mt-12"><?= $e($t('settings.statuses.groups.empty')) ?></div>
<?php endif; ?>
<?php foreach ($groupsList as $group): ?>
<?php
$groupId = max(0, (int) ($group['id'] ?? 0));
if ($groupId <= 0) {
continue;
}
$groupName = (string) ($group['name'] ?? ('#' . $groupId));
$groupColor = (string) ($group['color_hex'] ?? '#64748b');
$groupStatuses = is_array($statusesByGroup[$groupId] ?? null) ? $statusesByGroup[$groupId] : [];
?>
<article class="statuses-group-block mt-16">
<header class="statuses-group-block__head">
<h3 class="statuses-group-block__title">
<span class="statuses-color-dot" style="background: <?= $e($groupColor) ?>;"></span>
<?= $e($groupName) ?>
</h3>
<span class="muted"><?= $e($t('settings.statuses.hints.drag_statuses')) ?> <?= $e($t('settings.statuses.hints.auto_save_order')) ?></span>
</header>
<ul class="statuses-dnd-list js-dnd-list" data-order-scope="statuses-<?= $e((string) $groupId) ?>">
<?php foreach ($groupStatuses as $status): ?>
<?php $statusId = max(0, (int) ($status['id'] ?? 0)); ?>
<?php if ($statusId <= 0) continue; ?>
<li class="statuses-dnd-item" draggable="true" data-id="<?= $e((string) $statusId) ?>">
<div class="statuses-dnd-item__drag" title="<?= $e($t('settings.statuses.hints.drag_handle')) ?>">::</div>
<div class="statuses-dnd-item__content">
<form action="/settings/statuses/update" method="post" class="statuses-inline-form statuses-inline-form--row">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="status_id" value="<?= $e((string) $statusId) ?>">
<input type="text" name="name" class="form-control" maxlength="120" required value="<?= $e((string) ($status['name'] ?? '')) ?>" aria-label="<?= $e($t('settings.statuses.fields.name')) ?>">
<select name="group_id" class="form-control" required aria-label="<?= $e($t('settings.statuses.fields.group')) ?>">
<?php foreach ($groupsList as $groupOption): ?>
<?php $groupOptionId = max(0, (int) ($groupOption['id'] ?? 0)); ?>
<?php if ($groupOptionId <= 0) continue; ?>
<option value="<?= $e((string) $groupOptionId) ?>"<?= $groupOptionId === (int) ($status['group_id'] ?? 0) ? ' selected' : '' ?>>
<?= $e((string) ($groupOption['name'] ?? ('#' . $groupOptionId))) ?>
</option>
<?php endforeach; ?>
</select>
<label class="field-inline statuses-inline-check">
<input type="hidden" name="is_active" value="0">
<input type="checkbox" name="is_active" value="1"<?= ((int) ($status['is_active'] ?? 0)) === 1 ? ' checked' : '' ?>>
<span><?= $e($t('settings.statuses.fields.is_active')) ?></span>
</label>
<div class="statuses-code-readonly">
<span class="statuses-code-label"><?= $e($t('settings.statuses.fields.code')) ?>:</span>
<code><?= $e((string) ($status['code'] ?? '')) ?></code>
</div>
<button type="submit" class="btn btn--secondary"><?= $e($t('settings.statuses.actions.save')) ?></button>
</form>
<form action="/settings/statuses/delete" method="post" class="table-inline-action statuses-inline-delete" data-alert-confirm="<?= $e($t('settings.statuses.confirm.delete_status')) ?>" data-alert-confirm-title="<?= $e($t('settings.statuses.confirm.title')) ?>" data-alert-confirm-yes="<?= $e($t('settings.statuses.confirm.confirm')) ?>" data-alert-confirm-no="<?= $e($t('settings.statuses.confirm.cancel')) ?>">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="status_id" value="<?= $e((string) $statusId) ?>">
<button type="submit" class="btn btn--danger"><?= $e($t('settings.statuses.actions.delete')) ?></button>
</form>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php if ($groupStatuses === []): ?>
<p class="muted mt-12"><?= $e($t('settings.statuses.statuses.empty')) ?></p>
<?php endif; ?>
<?php if ($groupStatuses !== []): ?>
<form action="/settings/statuses/reorder" method="post" class="statuses-reorder-form js-reorder-form mt-12" data-order-scope="statuses-<?= $e((string) $groupId) ?>">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="group_id" value="<?= $e((string) $groupId) ?>">
<div class="js-order-inputs"></div>
</form>
<?php endif; ?>
</article>
<?php endforeach; ?>
</div>
<div class="content-tab-panel" data-tab-panel="groups-tab">
<h2 class="section-title"><?= $e($t('settings.statuses.groups.create_title')) ?></h2>
<form class="statuses-form mt-16" action="/settings/status-groups" method="post">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<label class="form-field">
<span class="field-label"><?= $e($t('settings.statuses.fields.name')) ?></span>
<input type="text" name="name" class="form-control" maxlength="120" required>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.statuses.fields.color')) ?></span>
<input type="color" name="color_hex" class="form-control statuses-color-input" value="#64748b">
</label>
<label class="field-inline">
<input type="hidden" name="is_active" value="0">
<input type="checkbox" name="is_active" value="1" checked>
<span><?= $e($t('settings.statuses.fields.is_active')) ?></span>
</label>
<p class="muted statuses-hint"><?= $e($t('settings.statuses.hints.code_auto')) ?></p>
<div class="form-actions">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.statuses.actions.add_group')) ?></button>
</div>
</form>
<h2 class="section-title mt-16"><?= $e($t('settings.statuses.groups.list_title')) ?></h2>
<p class="muted mt-12"><?= $e($t('settings.statuses.hints.drag_groups')) ?> <?= $e($t('settings.statuses.hints.auto_save_order')) ?></p>
<ul class="statuses-dnd-list js-dnd-list mt-12" data-order-scope="groups">
<?php foreach ($groupsList as $group): ?>
<?php
$groupId = max(0, (int) ($group['id'] ?? 0));
if ($groupId <= 0) {
continue;
}
?>
<li class="statuses-dnd-item" draggable="true" data-id="<?= $e((string) $groupId) ?>">
<div class="statuses-dnd-item__drag" title="<?= $e($t('settings.statuses.hints.drag_handle')) ?>">::</div>
<div class="statuses-dnd-item__content">
<form action="/settings/status-groups/update" method="post" class="statuses-inline-form statuses-inline-form--row-group">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="group_id" value="<?= $e((string) $groupId) ?>">
<input type="text" name="name" class="form-control" maxlength="120" required value="<?= $e((string) ($group['name'] ?? '')) ?>" aria-label="<?= $e($t('settings.statuses.fields.name')) ?>">
<input type="color" name="color_hex" class="form-control statuses-color-input" value="<?= $e((string) ($group['color_hex'] ?? '#64748b')) ?>" aria-label="<?= $e($t('settings.statuses.fields.color')) ?>">
<label class="field-inline statuses-inline-check">
<input type="hidden" name="is_active" value="0">
<input type="checkbox" name="is_active" value="1"<?= ((int) ($group['is_active'] ?? 0)) === 1 ? ' checked' : '' ?>>
<span><?= $e($t('settings.statuses.fields.is_active')) ?></span>
</label>
<div class="statuses-code-readonly">
<span class="statuses-code-label"><?= $e($t('settings.statuses.fields.code')) ?>:</span>
<code><?= $e((string) ($group['code'] ?? '')) ?></code>
</div>
<button type="submit" class="btn btn--secondary"><?= $e($t('settings.statuses.actions.save')) ?></button>
</form>
<form action="/settings/status-groups/delete" method="post" class="table-inline-action statuses-inline-delete" data-alert-confirm="<?= $e($t('settings.statuses.confirm.delete_group')) ?>" data-alert-confirm-title="<?= $e($t('settings.statuses.confirm.title')) ?>" data-alert-confirm-yes="<?= $e($t('settings.statuses.confirm.confirm')) ?>" data-alert-confirm-no="<?= $e($t('settings.statuses.confirm.cancel')) ?>">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="group_id" value="<?= $e((string) $groupId) ?>">
<button type="submit" class="btn btn--danger"><?= $e($t('settings.statuses.actions.delete')) ?></button>
</form>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php if ($groupsList === []): ?>
<p class="muted mt-12"><?= $e($t('settings.statuses.groups.empty')) ?></p>
<?php endif; ?>
<?php if ($groupsList !== []): ?>
<form action="/settings/status-groups/reorder" method="post" class="statuses-reorder-form js-reorder-form mt-12" data-order-scope="groups">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<div class="js-order-inputs"></div>
</form>
<?php endif; ?>
</div>
</section>
<script>
(function() {
var tabStorageKey = 'settings.statuses.active_tab';
var defaultTab = 'statuses-tab';
function activateTab(target) {
var normalizedTarget = (target === 'groups-tab' || target === 'statuses-tab') ? target : defaultTab;
tabButtons.forEach(function(btn) { btn.classList.remove('is-active'); });
tabPanels.forEach(function(panel) { panel.classList.remove('is-active'); });
var activeButton = document.querySelector('[data-tab-target="' + normalizedTarget + '"]');
var activePanel = document.querySelector('[data-tab-panel="' + normalizedTarget + '"]');
if (activeButton) activeButton.classList.add('is-active');
if (activePanel) activePanel.classList.add('is-active');
}
var tabButtons = document.querySelectorAll('[data-tab-target]');
var tabPanels = document.querySelectorAll('[data-tab-panel]');
try {
activateTab(localStorage.getItem(tabStorageKey) || defaultTab);
} catch (e) {
activateTab(defaultTab);
}
tabButtons.forEach(function(button) {
button.addEventListener('click', function() {
var target = button.getAttribute('data-tab-target');
activateTab(target);
try {
localStorage.setItem(tabStorageKey, target || defaultTab);
} catch (e) {}
});
});
function initDndList(list) {
var dragged = null;
var startIndex = -1;
function submitReorderForScope(scope) {
if (scope === '') return;
var form = document.querySelector('.js-reorder-form[data-order-scope="' + scope + '"]');
if (!form || form.getAttribute('data-submitting') === '1') return;
var sourceList = document.querySelector('.js-dnd-list[data-order-scope="' + scope + '"]');
if (!sourceList) return;
var holder = form.querySelector('.js-order-inputs');
if (!holder) return;
holder.innerHTML = '';
sourceList.querySelectorAll('.statuses-dnd-item').forEach(function(item) {
var id = item.getAttribute('data-id') || '';
if (id === '') return;
var input = document.createElement('input');
input.type = 'hidden';
input.name = 'order[]';
input.value = id;
holder.appendChild(input);
});
form.setAttribute('data-submitting', '1');
form.submit();
}
list.querySelectorAll('.statuses-dnd-item').forEach(function(item) {
item.addEventListener('dragstart', function() {
dragged = item;
startIndex = Array.prototype.indexOf.call(list.children, item);
item.classList.add('is-dragging');
});
item.addEventListener('dragend', function() {
item.classList.remove('is-dragging');
var endIndex = Array.prototype.indexOf.call(list.children, item);
var moved = startIndex >= 0 && endIndex >= 0 && startIndex !== endIndex;
var scope = list.getAttribute('data-order-scope') || '';
dragged = null;
startIndex = -1;
if (moved) {
submitReorderForScope(scope);
}
});
item.addEventListener('dragover', function(event) {
event.preventDefault();
if (!dragged || dragged === item) return;
var rect = item.getBoundingClientRect();
var before = event.clientY < rect.top + rect.height / 2;
list.insertBefore(dragged, before ? item : item.nextSibling);
});
});
}
document.querySelectorAll('.js-dnd-list').forEach(initDndList);
document.addEventListener('submit', function(event) {
var form = event.target;
if (!form || !form.matches || !form.matches('form[data-alert-confirm]')) return;
if (form.getAttribute('data-confirmed') === '1') {
form.removeAttribute('data-confirmed');
return;
}
var message = form.getAttribute('data-alert-confirm') || '';
if (message === '') return;
if (!window.OrderProAlerts || typeof window.OrderProAlerts.confirm !== 'function') return;
event.preventDefault();
window.OrderProAlerts.confirm({
title: form.getAttribute('data-alert-confirm-title') || 'Potwierdzenie',
message: message,
confirmLabel: form.getAttribute('data-alert-confirm-yes') || 'Potwierdz',
cancelLabel: form.getAttribute('data-alert-confirm-no') || 'Anuluj',
danger: true
}).then(function(accepted) {
if (!accepted) return;
form.setAttribute('data-confirmed', '1');
form.submit();
});
});
})();
</script>

View File

@@ -1,6 +1,6 @@
<section class="card">
<h1><?= $e($t('users.title')) ?></h1>
<p class="muted"><?= $e($t('users.description')) ?></p>
<h2 class="section-title"><?= $e($t('users.title')) ?></h2>
<p class="muted mt-12"><?= $e($t('users.description')) ?></p>
</section>
<section class="card mt-16">
@@ -18,7 +18,7 @@
</div>
<?php endif; ?>
<form class="users-form mt-16" action="/users" method="post" novalidate>
<form class="users-form mt-16" action="/settings/users" method="post" novalidate>
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<label class="form-field">

View File

@@ -4,29 +4,10 @@ declare(strict_types=1);
use App\Core\Application;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\Security\Csrf;
use App\Modules\Auth\AuthController;
use App\Modules\Auth\AuthMiddleware;
use App\Modules\Cron\CronJobRepository;
use App\Modules\GS1\GS1Service;
use App\Modules\Marketplace\MarketplaceController;
use App\Modules\Marketplace\MarketplaceRepository;
use App\Modules\ProductLinks\ChannelOffersRepository;
use App\Modules\ProductLinks\LinkMatcherService;
use App\Modules\ProductLinks\OfferImportService;
use App\Modules\ProductLinks\ProductLinksController;
use App\Modules\ProductLinks\ProductLinksRepository;
use App\Modules\ProductLinks\ProductLinksService;
use App\Modules\Products\ProductRepository;
use App\Modules\Products\ProductSkuGenerator;
use App\Modules\Products\ProductsController;
use App\Modules\Products\ProductService;
use App\Modules\Products\ProductValidator;
use App\Modules\Products\ShopProExportService;
use App\Modules\Settings\AppSettingsRepository;
use App\Modules\Settings\IntegrationRepository;
use App\Modules\Orders\OrdersController;
use App\Modules\Settings\SettingsController;
use App\Modules\Settings\ShopProClient;
use App\Modules\Users\UsersController;
return static function (Application $app): void {
@@ -36,70 +17,9 @@ return static function (Application $app): void {
$translator = $app->translator();
$authController = new AuthController($template, $auth, $translator);
$integrationRepository = new IntegrationRepository(
$app->db(),
(string) $app->config('app.integrations.secret', '')
);
$appSettingsRepository = new AppSettingsRepository($app->db());
$cronJobRepository = new CronJobRepository($app->db());
$usersController = new UsersController($template, $translator, $auth, $app->users(), $integrationRepository);
$marketplaceRepository = new MarketplaceRepository($app->db());
$shopProClient = new ShopProClient();
$channelOffersRepository = new ChannelOffersRepository($app->db());
$productLinksRepository = new ProductLinksRepository($app->db());
$linkMatcherService = new LinkMatcherService();
$productLinksService = new ProductLinksService(
$productLinksRepository,
$channelOffersRepository,
$integrationRepository,
$linkMatcherService,
$app->db()
);
$productLinksController = new ProductLinksController($translator, $auth, $productLinksService);
$offerImportService = new OfferImportService($shopProClient, $channelOffersRepository, $app->db());
$productRepository = new ProductRepository($app->db());
$settingsController = new SettingsController(
$template,
$translator,
$auth,
$app->migrator(),
$integrationRepository,
$shopProClient,
$offerImportService,
$cronJobRepository,
$appSettingsRepository,
$productRepository,
$app->db(),
$app->logger()
);
$productValidator = new ProductValidator();
$productService = new ProductService($app->db(), $productRepository, $productValidator);
$productSkuGenerator = new ProductSkuGenerator($appSettingsRepository, $productRepository);
$shopProExportService = new ShopProExportService($app->db(), $productRepository, $integrationRepository, $shopProClient);
$gs1Service = new GS1Service($productRepository, $appSettingsRepository);
$productsController = new ProductsController(
$template,
$translator,
$auth,
$productRepository,
$productService,
$productSkuGenerator,
$integrationRepository,
$productLinksService,
$shopProExportService,
$gs1Service
);
$marketplaceController = new MarketplaceController(
$template,
$translator,
$auth,
$marketplaceRepository,
$integrationRepository,
$shopProClient,
$productRepository,
$productService,
$productValidator
);
$usersController = new UsersController($template, $translator, $auth, $app->users());
$ordersController = new OrdersController($template, $translator, $auth, $app->orders());
$settingsController = new SettingsController($template, $translator, $auth, $app->migrator(), $app->orderStatuses());
$authMiddleware = new AuthMiddleware($auth);
$router->get('/health', static fn (Request $request): Response => Response::json([
@@ -110,7 +30,7 @@ return static function (Application $app): void {
$router->get('/', static function (Request $request) use ($auth): Response {
return $auth->check()
? Response::redirect('/dashboard')
? Response::redirect('/settings/users')
: Response::redirect('/login');
});
@@ -118,71 +38,23 @@ return static function (Application $app): void {
$router->post('/login', [$authController, 'login']);
$router->post('/logout', [$authController, 'logout'], [$authMiddleware]);
$router->get('/dashboard', static function (Request $request) use ($template, $auth, $translator, $integrationRepository): Response {
$user = $auth->user();
$html = $template->render('dashboard/index', [
'title' => $translator->get('dashboard.title'),
'activeMenu' => 'dashboard',
'user' => $user,
'csrfToken' => Csrf::token(),
'marketplaceIntegrations' => array_values(array_filter(
$integrationRepository->listByType('shoppro'),
static fn (array $row): bool => (bool) ($row['is_active'] ?? false)
)),
], 'layouts/app');
return Response::html($html);
}, [$authMiddleware]);
$router->get('/users', [$usersController, 'index'], [$authMiddleware]);
$router->get('/users', static fn (Request $request): Response => Response::redirect('/settings/users'), [$authMiddleware]);
$router->get('/orders', static fn (Request $request): Response => Response::redirect('/orders/list'), [$authMiddleware]);
$router->get('/orders/list', [$ordersController, 'index'], [$authMiddleware]);
$router->get('/orders/{id}', [$ordersController, 'show'], [$authMiddleware]);
$router->post('/users', [$usersController, 'store'], [$authMiddleware]);
$router->get('/products', [$productsController, 'index'], [$authMiddleware]);
$router->get('/products/show', [$productsController, 'show'], [$authMiddleware]);
$router->get('/products/{id}', [$productsController, 'show'], [$authMiddleware]);
$router->get('/marketplace', [$marketplaceController, 'index'], [$authMiddleware]);
$router->get('/marketplace/{integration_id}', [$marketplaceController, 'offers'], [$authMiddleware]);
$router->get('/marketplace/{integration_id}/product/{external_product_id}/edit', [$marketplaceController, 'editProduct'], [$authMiddleware]);
$router->post('/marketplace/{integration_id}/product/{external_product_id}/update', [$marketplaceController, 'updateProduct'], [$authMiddleware]);
$router->get('/marketplace/{integration_id}/categories', [$marketplaceController, 'categoriesJson'], [$authMiddleware]);
$router->get('/marketplace/{integration_id}/product/{external_product_id}/categories', [$marketplaceController, 'productCategoriesJson'], [$authMiddleware]);
$router->post('/marketplace/{integration_id}/product/{external_product_id}/categories', [$marketplaceController, 'saveProductCategoriesJson'], [$authMiddleware]);
$router->get('/products/links', [$productsController, 'links'], [$authMiddleware]);
$router->get('/products/{id}/links', [$productsController, 'links'], [$authMiddleware]);
$router->get('/products/{id}/links/suggestions', [$productsController, 'linkSuggestions'], [$authMiddleware]);
$router->get('/products/create', [$productsController, 'create'], [$authMiddleware]);
$router->post('/products', [$productsController, 'store'], [$authMiddleware]);
$router->get('/products/edit', [$productsController, 'edit'], [$authMiddleware]);
$router->post('/products/update', [$productsController, 'update'], [$authMiddleware]);
$router->post('/products/delete', [$productsController, 'destroy'], [$authMiddleware]);
$router->post('/products/images/upload', [$productsController, 'uploadImage'], [$authMiddleware]);
$router->post('/products/images/set-main', [$productsController, 'setMainImage'], [$authMiddleware]);
$router->post('/products/images/delete', [$productsController, 'deleteImage'], [$authMiddleware]);
$router->post('/products/next-sku', [$productsController, 'nextSku'], [$authMiddleware]);
$router->post('/products/links/create', [$productLinksController, 'create'], [$authMiddleware]);
$router->post('/products/links/relink', [$productLinksController, 'relink'], [$authMiddleware]);
$router->post('/products/links/unlink', [$productLinksController, 'unlink'], [$authMiddleware]);
$router->post('/products/{id}/links', [$productLinksController, 'create'], [$authMiddleware]);
$router->post('/products/{id}/links/{mapId}/relink', [$productLinksController, 'relink'], [$authMiddleware]);
$router->post('/products/{id}/links/{mapId}/unlink', [$productLinksController, 'unlink'], [$authMiddleware]);
$router->post('/products/import/shoppro', [$settingsController, 'importProducts'], [$authMiddleware]);
$router->post('/products/export/shoppro', [$productsController, 'exportShopPro'], [$authMiddleware]);
$router->post('/products/{id}/assign-ean', [$productsController, 'assignGs1Ean'], [$authMiddleware]);
$router->get('/settings/integrations', static fn (Request $request): Response => Response::redirect('/settings/integrations/shoppro'), [$authMiddleware]);
$router->get('/settings/integrations/shoppro', [$settingsController, 'integrations'], [$authMiddleware]);
$router->post('/settings/integrations/save', [$settingsController, 'saveIntegration'], [$authMiddleware]);
$router->post('/settings/integrations/shoppro/save', [$settingsController, 'saveIntegration'], [$authMiddleware]);
$router->post('/settings/integrations/test', [$settingsController, 'testIntegration'], [$authMiddleware]);
$router->post('/settings/integrations/shoppro/test', [$settingsController, 'testIntegration'], [$authMiddleware]);
$router->post('/settings/integrations/import-one-product', [$settingsController, 'importOneProduct'], [$authMiddleware]);
$router->post('/settings/integrations/shoppro/import-one-product', [$settingsController, 'importOneProduct'], [$authMiddleware]);
$router->post('/settings/integrations/import-offers-cache', [$settingsController, 'importOffersCache'], [$authMiddleware]);
$router->post('/settings/integrations/shoppro/import-offers-cache', [$settingsController, 'importOffersCache'], [$authMiddleware]);
$router->get('/settings/users', [$usersController, 'index'], [$authMiddleware]);
$router->post('/settings/users', [$usersController, 'store'], [$authMiddleware]);
$router->get('/settings', static fn (Request $request): Response => Response::redirect('/settings/users'), [$authMiddleware]);
$router->get('/settings/database', [$settingsController, 'database'], [$authMiddleware]);
$router->post('/settings/database/migrate', [$settingsController, 'migrate'], [$authMiddleware]);
$router->get('/settings/cron', [$settingsController, 'cron'], [$authMiddleware]);
$router->post('/settings/cron/save', [$settingsController, 'saveCronSettings'], [$authMiddleware]);
$router->get('/settings/gs1', [$settingsController, 'gs1'], [$authMiddleware]);
$router->post('/settings/gs1/save', [$settingsController, 'gs1Save'], [$authMiddleware]);
$router->get('/settings/products', [$settingsController, 'products'], [$authMiddleware]);
$router->post('/settings/products/save', [$settingsController, 'productsSave'], [$authMiddleware]);
$router->get('/settings/statuses', [$settingsController, 'statuses'], [$authMiddleware]);
$router->post('/settings/status-groups', [$settingsController, 'createStatusGroup'], [$authMiddleware]);
$router->post('/settings/status-groups/update', [$settingsController, 'updateStatusGroup'], [$authMiddleware]);
$router->post('/settings/status-groups/delete', [$settingsController, 'deleteStatusGroup'], [$authMiddleware]);
$router->post('/settings/status-groups/reorder', [$settingsController, 'reorderStatusGroups'], [$authMiddleware]);
$router->post('/settings/statuses/create', [$settingsController, 'createStatus'], [$authMiddleware]);
$router->post('/settings/statuses/update', [$settingsController, 'updateStatus'], [$authMiddleware]);
$router->post('/settings/statuses/delete', [$settingsController, 'deleteStatus'], [$authMiddleware]);
$router->post('/settings/statuses/reorder', [$settingsController, 'reorderStatuses'], [$authMiddleware]);
};

View File

@@ -13,20 +13,11 @@ use App\Core\Support\Logger;
use App\Core\Support\Session;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use App\Modules\Cron\CronJobProcessor;
use App\Modules\Cron\CronJobRepository;
use App\Modules\Cron\CronJobType;
use App\Modules\Cron\ProductLinksHealthCheckHandler;
use App\Modules\Cron\ShopProOfferTitlesRefreshHandler;
use App\Modules\ProductLinks\ChannelOffersRepository;
use App\Modules\ProductLinks\OfferImportService;
use App\Modules\ProductLinks\ProductLinksRepository;
use App\Modules\Settings\AppSettingsRepository;
use App\Modules\Settings\IntegrationRepository;
use App\Modules\Settings\ShopProClient;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\OrderStatusRepository;
use App\Modules\Users\UserRepository;
use PDO;
use Throwable;
use PDO;
final class Application
{
@@ -34,6 +25,8 @@ final class Application
private Template $template;
private AuthService $authService;
private UserRepository $userRepository;
private OrdersRepository $ordersRepository;
private OrderStatusRepository $orderStatusRepository;
private Migrator $migrator;
private PDO $db;
private Logger $logger;
@@ -54,6 +47,8 @@ final class Application
$this->template = new Template((string) $this->config('app.view_path'), $this->translator);
$this->db = ConnectionFactory::make((array) $this->config('database', []));
$this->userRepository = new UserRepository($this->db);
$this->ordersRepository = new OrdersRepository($this->db);
$this->orderStatusRepository = new OrderStatusRepository($this->db);
$this->migrator = new Migrator(
$this->db,
(string) $this->config('app.migrations_path', $this->basePath('database/migrations'))
@@ -75,7 +70,6 @@ final class Application
public function run(): void
{
$request = Request::capture();
$this->maybeRunCronOnWeb($request);
$response = $this->router->dispatch($request);
$response->send();
}
@@ -114,6 +108,16 @@ final class Application
return $this->userRepository;
}
public function orderStatuses(): OrderStatusRepository
{
return $this->orderStatusRepository;
}
public function orders(): OrdersRepository
{
return $this->ordersRepository;
}
public function db(): PDO
{
return $this->db;
@@ -211,74 +215,7 @@ final class Application
private function maybeRunCronOnWeb(Request $request): void
{
if ($request->method() !== 'GET') {
return;
}
if ($request->path() === '/health') {
return;
}
$enabled = (bool) $this->config('app.cron.run_on_web_default', false);
$limit = max(1, min(100, (int) $this->config('app.cron.web_limit_default', 5)));
try {
$appSettings = new AppSettingsRepository($this->db);
$enabled = $appSettings->getBool('cron_run_on_web', $enabled);
$limit = max(1, min(100, $appSettings->getInt('cron_web_limit', $limit)));
} catch (Throwable $exception) {
$this->logger->error('Cron web settings load failed', [
'message' => $exception->getMessage(),
]);
}
if (!$enabled) {
return;
}
if ($this->isWebCronThrottled(20)) {
return;
}
if (!$this->acquireWebCronLock()) {
return;
}
try {
$cronJobs = new CronJobRepository($this->db);
$processor = new CronJobProcessor($cronJobs);
$integrationRepository = new IntegrationRepository(
$this->db,
(string) $this->config('app.integrations.secret', '')
);
$offersRepository = new ChannelOffersRepository($this->db);
$linksRepository = new ProductLinksRepository($this->db);
$shopProClient = new ShopProClient();
$offerImportService = new OfferImportService($shopProClient, $offersRepository, $this->db);
$linksHealthCheckHandler = new ProductLinksHealthCheckHandler(
$integrationRepository,
$offerImportService,
$linksRepository,
$offersRepository
);
$offerTitlesRefreshHandler = new ShopProOfferTitlesRefreshHandler(
$integrationRepository,
$offerImportService
);
$processor->registerHandler(CronJobType::PRODUCT_LINKS_HEALTH_CHECK, $linksHealthCheckHandler);
$processor->registerHandler(CronJobType::SHOPPRO_OFFER_TITLES_REFRESH, $offerTitlesRefreshHandler);
$result = $processor->run($limit);
$this->logger->info('Cron web run completed', $result);
} catch (Throwable $exception) {
$this->logger->error('Cron web run failed', [
'message' => $exception->getMessage(),
]);
} finally {
$this->releaseWebCronLock();
}
return;
}
private function isWebCronThrottled(int $minIntervalSeconds): bool

View File

@@ -22,7 +22,7 @@ final class AuthController
public function showLogin(Request $request): Response
{
if ($this->auth->check()) {
return Response::redirect('/dashboard');
return Response::redirect('/settings/users');
}
$html = $this->template->render('auth/login', [
@@ -59,7 +59,7 @@ final class AuthController
return Response::redirect('/login');
}
return Response::redirect('/dashboard');
return Response::redirect('/settings/users');
}
public function logout(Request $request): Response

Some files were not shown because too many files have changed in this diff Show More