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:
@@ -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
|
||||
|
||||
822
.vscode/ftp-kr.sync.cache.json
vendored
822
.vscode/ftp-kr.sync.cache.json
vendored
File diff suppressed because it is too large
Load Diff
18
AGENTS.md
18
AGENTS.md
@@ -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).
|
||||
|
||||
517
DOCS/API.md
517
DOCS/API.md
@@ -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
152
DOCS/ARCHITECTURE.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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/`
|
||||
@@ -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.
|
||||
44
DOCS/ORDERS_SCHEMA_DRAFT.md
Normal file
44
DOCS/ORDERS_SCHEMA_DRAFT.md
Normal 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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
131
DOCS/TECH_CHANGELOG.md
Normal 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.
|
||||
@@ -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/
|
||||
@@ -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)
|
||||
@@ -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()`
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
115
archive/2026-03-02_users-only-reset/bin/cron.php
Normal file
115
archive/2026-03-02_users-only-reset/bin/cron.php
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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; ?>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
89
bin/cron.php
89
bin/cron.php
@@ -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);
|
||||
|
||||
724
bin/deploy_and_seed_orders.php
Normal file
724
bin/deploy_and_seed_orders.php
Normal 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;
|
||||
}
|
||||
}
|
||||
81
bin/fill_order_item_images.php
Normal file
81
bin/fill_order_item_images.php
Normal 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
216
bin/fix_status_codes.php
Normal 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);
|
||||
}
|
||||
123
bin/randomize_order_statuses.php
Normal file
123
bin/randomize_order_statuses.php
Normal 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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
240
database/drafts/20260302_orders_schema_v1.sql
Normal file
240
database/drafts/20260302_orders_schema_v1.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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 = '';
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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
21
phpunit.xml
Normal 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
@@ -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}}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
36
resources/views/components/order-status-panel.php
Normal file
36
resources/views/components/order-status-panel.php
Normal 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
84
resources/views/orders/list.php
Normal file
84
resources/views/orders/list.php
Normal 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>
|
||||
249
resources/views/orders/show.php
Normal file
249
resources/views/orders/show.php
Normal 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">← <?= $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>
|
||||
@@ -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)): ?>
|
||||
|
||||
341
resources/views/settings/statuses.php
Normal file
341
resources/views/settings/statuses.php
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
170
routes/web.php
170
routes/web.php
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user