Compare commits

..

29 Commits

Author SHA1 Message Date
fba215b372 fix: linki produktow z permutacja atrybutow w feedzie Google (v0.350)
Separator URL miedzy parami attr-val zmieniony z "/" na "_" w generatorze
feedu (ProductRepository::appendCombinationToXml). Wzorzec routingu
pp_routes rozszerzony do [0-9_-]+ w Helpers::htacces (oba warianty:
seo_link i fallback p-id-name). LayoutEngine konwertuje "_" -> "|"
przed wywolaniem ProductRepository::findCached — format DB pozostaje "|".
Partial product-attribute.php preselectuje wartosc z permutation_hash
URL (forced_value_id), co poprawia UX wejscia z linka feedu.

Suita: 834 -> 841 testow (+7), 2330 assertions.

Wymagane akcje na produkcji po deployu: regeneracja pp_routes
(Helpers::htacces), wyczyszczenie klucza pp_routes:all w Redis,
regeneracja google-feed.xml, resubmit feedu w GMC.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 01:58:29 +02:00
Jacek
0de47f4e62 update 2026-04-20 23:45:19 +02:00
Jacek
abb794de36 build: ver_0.349 - koszt transportu na podsumowaniu koszyka 2026-04-20 20:51:24 +02:00
Jacek
eee22ef1c4 fix: poprawny koszt transportu na /koszyk-podsumowanie
Na podstronie /koszyk-podsumowanie transport z flaga delivery_free=1
byl pokazywany zawsze za 0,00 zl, niezaleznie od wartosci koszyka.
Teraz kontroler wylicza transport_cost_effective i free_delivery_applies
uwzgledniajac prog settings.free_delivery, a szablon uzywa tych kluczy.

- Nowa chroniona metoda ShopBasketController::calculateTransportCostForSummary
- Dodane 6 testow jednostkowych (ShopBasketControllerSummaryViewTest)
- Suita: 834 testy / 2318 assertions OK

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:50:15 +02:00
Jacek
494cb580d3 update 2026-04-19 11:22:36 +02:00
Jacek
23bd85a04a build: ver_0.348 - etykiety niestandardowe produktów 2026-04-19 11:12:28 +02:00
Jacek
9577d4944a feat: custom labels toggle and inline editing in product list
Adds session-based show/hide toggle for custom labels in admin product list, inline editable fields for custom_label_0..4, and label suggestions with custom entry support. Includes repository/controller updates, UI fixes, tests, and PAUL docs release updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 11:09:19 +02:00
Jacek
41e491c6b7 update 2026-04-18 23:52:11 +02:00
Jacek
e195ffc841 build: ver_0.347 - naprawa zapisu scontainers 2026-04-18 23:25:45 +02:00
Jacek
5b66720f7c fix: scontainers edit saves existing record instead of creating new
Fixes static container admin edit flow by preserving id in hiddenFields and adding route-id fallback during save.
Adds regression tests for edit/create id behavior, updates release docs (changelog/testing/CLAUDE), and appends SonarQube open issues to docs/TODO.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 22:56:14 +02:00
Jacek
c611b012c6 update 2026-04-16 23:12:41 +02:00
Jacek
3fa3d72758 build: ver_0.346 - Fix usuwania wszystkich dodatkowych pól produktu
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 22:52:32 +02:00
Jacek
1ef6dc9092 fix: Custom fields delete bug — usunięcie wszystkich pól produktu nie działało
Dodano hidden marker custom_field_name_present w formularzu edycji produktu.
Zmieniono warunek w ProductRepository z array_key_exists('custom_field_name')
na array_key_exists('custom_field_name_present') — jQuery .serialize() pomijał
klucz pustej tablicy gdy wszystkie pola usunięte. Test jednostkowy dodany.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 22:51:57 +02:00
Jacek
b03816e8ec update 2026-03-25 21:35:44 +01:00
Jacek
591f2787ca build: ver_0.345 - Checkout flow fix (redirect + TTL token + logging)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:33:25 +01:00
Jacek
e7b058c275 fix: Checkout flow — summaryView redirect fix + TTL token + order logging
- Usunięty błędny guard w summaryView() blokujący kolejne zamówienia
- Token zamówienia z jednorazowego na TTL 30 min (multi-tab safe)
- Logowanie błędów zamówień do logs/logs-order-YYYY-MM-DD.log
- Redirect przy złym tokenie na /koszyk-podsumowanie zamiast /koszyk
- Double-submit guard przeniesiony przed sprawdzenie tokena

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:32:46 +01:00
Jacek
cbda17a91e update 2026-03-25 19:16:17 +01:00
Jacek
60c346718e feat: DataLayer GA4 analytics fix — poprawka eventów ecommerce
Naprawione eventy purchase, begin_checkout, view_item, add_to_cart
do formatu GA4 (item_id/item_name zamiast id/name, currency PLN,
google_business_vertical, poprawne typy danych).
Dodany nowy event view_cart na stronie koszyka.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:01:22 +01:00
Jacek
b1a6763f0d update 2026-03-22 23:55:23 +01:00
Jacek
d3b4cbec5d update 2026-03-19 19:46:17 +01:00
Jacek
99c7a3e5d8 build: ver_0.344 - Edycja personalizacji produktu w koszyku
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:45:37 +01:00
Jacek
ae016e362b feat: edycja personalizacji produktu w koszyku
Nowa metoda basketUpdateCustomFields() w ShopBasketController — AJAX endpoint
z walidacją required fields, przeliczaniem product_code (MD5 hash) i merge
duplikatów. UI: przycisk "Edytuj personalizację" + formularz inline + JS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:45:02 +01:00
Jacek
10f9dfd85f build: ver_0.343 - Custom fields: type + is_required + obsługa obrazków w koszyku
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:37:36 +01:00
Jacek
131c26799f fix: custom fields - type/is_required przy kopiowaniu produktu + obsługa obrazków w koszyku
ProductRepository: kopiowanie custom fields uwzględnia pola type i is_required.
product-custom-fields.php: ochrona XSS, obsługa pola image, fallback typu na text.
SonarQube 0.343: nowe issues dodane do TODO.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:36:54 +01:00
Jacek
836b1c2596 update 2026-03-19 12:28:50 +01:00
Jacek
8815d7842f build: ver_0.342 - Apilo email z danymi zamówienia + infinite retry 2026-03-19 11:24:23 +01:00
Jacek
72864d18ba fix: Apilo email z danymi zamówienia + infinite retry co 30 min dla order jobów
- Email notyfikacji zawiera numer zamówienia, klienta, datę, kwotę
- Order joby (send_order, sync_payment, sync_status) ponawiane w nieskończoność co 30 min
- Rozróżnienie PONAWIANY vs TRWAŁY BŁĄD w emailu
- Cleanup stuck jobów po udanym wysłaniu zamówienia
- +2 testy infinite retry w CronJobRepositoryTest

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:23:02 +01:00
Jacek
4cda46d4bc update 2026-03-16 10:01:47 +01:00
Jacek
ef58098e90 build: ver_0.341 - bugfix wysyłka zamówień do Apilo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 10:00:25 +01:00
110 changed files with 10027 additions and 5854 deletions

View File

@@ -1,6 +1,6 @@
# shopPRO Koniec Pracy (release workflow)
# shopPRO - Koniec Pracy (release workflow)
Execute the full release workflow for shopPRO. This is a sequential pipeline each step depends on the previous one succeeding. Stop and report if any step fails.
Execute the full release workflow for shopPRO. This is a sequential pipeline - each step depends on the previous one succeeding. Stop and report if any step fails.
## Step 1: Run tests
@@ -8,7 +8,7 @@ Run the full PHPUnit test suite:
```bash
php phpunit.phar
```
All tests must pass. If any test fails, stop here do not proceed to commit. Report the failures and wait for instructions.
All tests must pass. If any test fails, stop here - do not proceed to commit. Report the failures and wait for instructions.
## Step 1b: SonarQube scan
@@ -19,20 +19,20 @@ sonar-scanner
After the scan completes, query the SonarQube issues via MCP tool `mcp__sonarqube__issues` with `project_key: "shopPRO"` and `resolved: false`. Fetch all open issues (bugs, vulnerabilities, code smells).
Then open `docs/TODO.md` and append the found issues at the bottom under a new section:
Then open `.paul/docs/TODO.md` and append the found issues at the bottom under a new section:
```markdown
## SonarQube {VERSION} ({DATE})
## SonarQube - {VERSION} ({DATE})
- [ ] [SEVERITY] FILENAME:LINE description (rule)
- [ ] [SEVERITY] FILENAME:LINE - description (rule)
- [ ] ...
```
Rules:
- Only add issues that are NOT already present in `docs/TODO.md`
- Only add issues that are NOT already present in `.paul/docs/TODO.md`
- Group by type: first Bugs/Vulnerabilities, then Code Smells
- Skip INFO severity Code Smells only include MINOR and above
- If there are no new issues, write: `## SonarQube {VERSION} brak nowych issues`
- Skip INFO severity Code Smells - only include MINOR and above
- If there are no new issues, write: `## SonarQube - {VERSION} - brak nowych issues`
## Step 2: Determine version
@@ -40,24 +40,24 @@ Read the latest git tag to determine the current version number:
```bash
git tag --sort=-v:refname | head -1
```
The new version is the previous version incremented by 1 (e.g., v0.333 v0.334). Use this version number throughout the remaining steps.
The new version is the previous version incremented by 1 (e.g., v0.333 -> v0.334). Use this version number throughout the remaining steps.
## Step 3: Update documentation
Update these docs files **only if** changes in this session affect them:
Update these docs files **only if** changes in this session affect them.
Do not update files in root `docs/` directory.
| File | When to update |
|------|---------------|
| `docs/CHANGELOG.md` | Always add a new version entry at the top describing what changed |
| `docs/TESTING.md` | If tests were added/removed update test count and structure |
| `CLAUDE.md` | If test count changed — update the "Current suite" line |
| `docs/DATABASE_STRUCTURE.md` | If database schema changed |
| `docs/PROJECT_STRUCTURE.md` | If architecture/files changed significantly |
| `docs/FORM_EDIT_SYSTEM.md` | If form system was modified |
| `.paul/docs/CHANGELOG.md` | Always - add a new version entry at the top describing what changed |
| `.paul/docs/TESTING.md` | If tests were added/removed - update test count and structure |
| `.paul/docs/DB_SCHEMA.md` | If database schema changed |
| `.paul/docs/ARCHITECTURE.md` | If architecture/files changed significantly |
| `.paul/docs/FORMS.md` | If form system was modified |
## Step 4: SQL migrations
If database schema changes were made, create a migration file at `migrations/{version}.sql` (e.g., `migrations/0.334.sql`). Do NOT put SQL files in `updates/` the build script reads from `migrations/` automatically.
If database schema changes were made, create a migration file at `migrations/{version}.sql` (e.g., `migrations/0.334.sql`). Do NOT put SQL files in `updates/` - the build script reads from `migrations/` automatically.
If no DB changes were made, skip this step.

4
.claude/memory/MEMORY.md Normal file
View File

@@ -0,0 +1,4 @@
# Memory Index
- [feedback_git_push_retry.md](feedback_git_push_retry.md) — Git push often fails on first attempt, always retry
- [feedback_updateignore_sonarqube.md](feedback_updateignore_sonarqube.md) — Never include .scannerwork/ and sonar-project.properties in update ZIPs

View File

@@ -0,0 +1,10 @@
---
name: git push retry
description: Git push to project-pro.pl often fails on first attempt - always retry before reporting failure
type: feedback
---
Git push do origin (git.project-pro.pl) czasem nie wchodzi za pierwszym razem. Zawsze ponawiaj push przed zgłoszeniem problemu użytkownikowi.
**Why:** Serwer git czasem odrzuca pierwsze połączenie (problem z autentykacją/połączeniem).
**How to apply:** Przy `git push` — jeśli pierwszy attempt fail, od razu ponów. Dopiero po 2-3 nieudanych próbach zgłoś problem.

View File

@@ -0,0 +1,10 @@
---
name: updateignore sonarqube
description: Never include .scannerwork/ and sonar-project.properties in update ZIP packages
type: feedback
---
Do paczki ZIP z aktualizacją nie dodawać katalogu `.scannerwork/` ani pliku `sonar-project.properties`.
**Why:** Są to pliki SonarQube — narzędzie deweloperskie, nie należą na serwer klienta.
**How to apply:** Upewnij się, że `.updateignore` zawiera te wpisy. Jeśli po buildzie w logu widać te pliki — naprawić `.updateignore` przed commitowaniem paczki.

View File

@@ -0,0 +1,14 @@
---
name: SonarQube scanner location
description: Path to sonar-scanner CLI installed locally with bundled JRE
type: reference
---
SonarQube scanner zainstalowany w `C:\tools\sonar-scanner-6.2.1.4610-windows-x64\bin\sonar-scanner.bat`
Dodany do PATH usera — po restarcie terminala dostępny jako `sonar-scanner`.
W bieżącej sesji bash używaj pełnej ścieżki: `"C:/tools/sonar-scanner-6.2.1.4610-windows-x64/bin/sonar-scanner.bat"`
Konfiguracja projektu: `sonar-project.properties` w katalogu głównym shopPRO.
Dashboard: https://sonar.project-pro.pl/dashboard?id=shopPRO

View File

@@ -1,12 +1,12 @@
# shopPRO
# shopPRO
## What This Is
Autorski silnik sklepu internetowego pisany od podstaw odpowiednik WooCommerce lub PrestaShop, ale bez zależności od zewnętrznych platform. Składa się z panelu administratora (zarządzanie zamówieniami, produktami, klientami) oraz części frontowej dla klienta końcowego.
Autorski silnik sklepu internetowego pisany od podstaw - odpowiednik WooCommerce lub PrestaShop, ale bez zależności od zewnętrznych platform. Składa się z panelu administratora (zarządzanie zamówieniami, produktami, klientami) oraz części frontowej dla klienta końcowego.
## Core Value
Właściciel sklepu internetowego ma pełną kontrolę nad sprzedażą online produktami, zamówieniami i klientami w jednym spójnym systemie pisanym od podstaw, bez narzutów zewnętrznych platform.
Właściciel sklepu internetowego ma pełną kontrolę nad sprzedażą online - produktami, zamówieniami i klientami - w jednym spójnym systemie pisanym od podstaw, bez narzutów zewnętrznych platform.
## Current State
@@ -14,22 +14,25 @@ Właściciel sklepu internetowego ma pełną kontrolę nad sprzedażą online
|-----------|-------|
| Version | 0.333 |
| Status | Production |
| Last Updated | 2026-03-12 |
| Last Updated | 2026-04-30 |
## Requirements
### Validated (Shipped)
- [x] Panel administratora zarządzanie produktami, kategoriami, atrybutami
- [x] Panel administratora zarządzanie zamówieniami
- [x] Panel administratora zarządzanie klientami
- [x] Część frontowa przeglądanie i kupowanie produktów
- [x] Panel administratora - zarządzanie produktami, kategoriami, atrybutami
- [x] Panel administratora - zarządzanie zamówieniami
- [x] Panel administratora - zarządzanie klientami
- [x] Część frontowa - przeglądanie i kupowanie produktów
- [x] Koszyk i składanie zamówień
- [x] Integracje płatności i dostaw
- [x] REST API (ordersPRO + Ekomi)
- [x] Redis caching
- [x] Ochrona przed podwójnym składaniem zamówienia
- [x] Domain-Driven Architecture (migracja z legacy zakończona)
- [x] Szybka edycja custom_label_0..4 na liscie produktow admina (toggle sesyjny + autocomplete)
- [x] Poprawna kalkulacja kosztu transportu na /koszyk-podsumowanie (fix delivery_free bez uwzglednienia progu)
- [x] Linki produktów z permutacją w feedzie Google działają (separator `_` w URL, konwersja `_``|` w warstwie front, regex `[0-9_-]+` w pp_routes)
### Active (In Progress)
@@ -41,7 +44,7 @@ Właściciel sklepu internetowego ma pełną kontrolę nad sprzedażą online
### Out of Scope
- Multitenancy (wiele sklepów w jednej instancji) nie planowane
- Multitenancy (wiele sklepów w jednej instancji) - nie planowane
## Target Users
@@ -65,7 +68,7 @@ Właściciel sklepu internetowego ma pełną kontrolę nad sprzedażą online
### Technical Constraints
- PHP < 8.0 na produkcji (brak `match`, named arguments, union types)
- Medoo ORM prepared statements bez wyjątków
- Medoo ORM - prepared statements bez wyjątków
- Redis wymagany dla cache
### Business Constraints
@@ -78,12 +81,16 @@ Właściciel sklepu internetowego ma pełną kontrolę nad sprzedażą online
| DDD + DI zamiast legacy architektury | Testowalność, separacja odpowiedzialności | 2025 | Active |
| PHP < 8.0 kompatybilność | Klienci na starszych serwerach | 2025 | Active |
| Własny silnik zamiast frameworka | Pełna kontrola, brak narzutów | - | Active |
| `id` w tabbed FormEdit przez `hiddenFields` | Zapobiega insert zamiast update przy edycji encji | 2026-04-18 | Active |
| Inline custom labels w product list przez sesyjny toggle | Szybszy workflow dla Google XML bez wejscia w edycje produktu | 2026-04-19 | Active |
| Kalkulacja kosztu transportu na /koszyk-podsumowanie w kontrolerze (nie w szablonie) | Spojnosc logiki progu darmowej dostawy miedzy /koszyk i /koszyk-podsumowanie | 2026-04-20 | Active |
| Separator URL permutacji `_` zamiast `/` (DB pozostaje `|`) | Jeden segment URL dopasowywalny przez pp_routes; konwersja `_``|` w warstwie front | 2026-04-30 | Active |
## Success Metrics
| Metric | Target | Current | Status |
|--------|--------|---------|--------|
| Testy | >800 | 810 | On track |
| Testy | >800 | 841 | On track |
| Pokrycie architektury DDD | 100% | 100% | Achieved |
## Tech Stack
@@ -102,14 +109,14 @@ Właściciel sklepu internetowego ma pełną kontrolę nad sprzedażą online
See: .paul/SPECIAL-FLOWS.md
Quick Reference:
- /feature-dev Nowe funkcje, większe zmiany (required)
- /koniec-pracy Release, update package (required)
- /frontend-design Komponenty UI, szablony widoków
- /code-review Przegląd kodu przed release
- /simplify Upraszczanie po implementacji
- /claude-md-improver Utrzymanie CLAUDE.md
- /zapisz + /wznow Zapis i wznowienie sesji
- /feature-dev -> Nowe funkcje, większe zmiany (required)
- /koniec-pracy -> Release, update package (required)
- /frontend-design -> Komponenty UI, szablony widoków
- /code-review -> Przegląd kodu przed release
- /simplify -> Upraszczanie po implementacji
- /claude-md-improver -> Utrzymanie CLAUDE.md
- /zapisz + /wznow -> Zapis i wznowienie sesji
---
*PROJECT.md Updated when requirements or context change*
*Last updated: 2026-03-12*
*PROJECT.md - Updated when requirements or context change*
*Last updated: 2026-04-30 after Phase 18*

View File

@@ -1,4 +1,4 @@
# Roadmap: shopPRO
# Roadmap: shopPRO
## Overview
@@ -6,9 +6,9 @@ shopPRO to autorski silnik sklepu internetowego rozwijany iteracyjnie. Projekt j
## Current Milestone
**Security hardening** (v0.33x)
Status: In progress
Phases: 3 of 4 complete
**Feature — Product list custom labels quick edit**
Status: Complete
Phases: 1 of 1 complete
## Phases
@@ -35,6 +35,21 @@ Status: Planning
|-------|------|-------|--------|-----------|
| 7 | Coupon Fatal Error — order placement crash | 1 | Done | 2026-03-15 |
| 8 | Apilo orders not sending — diagnoza i naprawa | 1 | Done | 2026-03-16 |
| 9 | Apilo email notification + infinite retry | 1 | Done | 2026-03-19 |
| 15 | Scontainers edit saves as new record | 1 | Done | 2026-04-18 |
| 17 | Cart summary transport cost fix | 1 | Done | 2026-04-20 |
| 18 | Google feed permutation URL fix | 1 | Done | 2026-04-30 |
## Feature
| Phase | Name | Plans | Status | Completed |
|-------|------|-------|--------|-----------|
| 10 | Edycja personalizacji produktu w koszyku | 1 | Done | 2026-03-19 |
| 11 | DataLayer GA4 analytics fix | 1 | Done | 2026-03-25 |
| 12 | summaryView redirect fix — double order block | 1 | Done | 2026-03-25 |
| 13 | Basket logging + TTL token fix | 1 | Done | 2026-03-25 |
| 14 | Custom fields delete bug — usunięcie wszystkich pól | 1 | Done | 2026-04-16 |
| 16 | Product list custom labels quick edit | 1 | Done | 2026-04-19 |
## Phase Details
@@ -66,4 +81,56 @@ Status: Planning
---
*Roadmap created: 2026-03-12*
*Last updated: 2026-03-16*
### Phase 11 — DataLayer GA4 analytics fix
**Problem:** Eventy dataLayer ecommerce (purchase, begin_checkout, view_item, add_to_cart) używają starego formatu UA (id/name zamiast item_id/item_name), brak currency w view_item, price:0 w purchase, brak eventu view_cart. Remarketing dynamiczny i konwersje GA4 nie działają poprawnie.
**Scope:** Poprawka 4 istniejących eventów do formatu GA4 + dodanie nowego eventu view_cart na stronie koszyka.
**Reference:** `poprawki_datalayer_projectpro.md` — audyt analityki z pomysloweprezenty.pl
### Phase 12 — summaryView redirect fix
**Problem:** Po złożeniu pierwszego zamówienia, guard w `summaryView()` sprawdzał sesyjny `order-submit-last-order-id` i redirectował na stronę starego zamówienia. Blokował dostęp do `/koszyk-podsumowanie` dla kolejnych zamówień. Poprawka z instancji klienta (change.md) do wdrożenia globalnie.
**Scope:** Usunięcie bloku redirect z `summaryView()` w `ShopBasketController.php`. Double-submit protection w `basketSave()` pozostaje bez zmian.
### Phase 13 — Basket logging + TTL token fix
**Problem:** Brak logowania w basketSave() uniemożliwia diagnozę błędów zamówień. Token zamówienia jednorazowy — nadpisywany przy każdym wejściu na podsumowanie, co powoduje że druga karta, "wstecz" lub odświeżenie unieważnia formularz.
**Scope:** Dodanie metody logOrder() z 4 punktami logowania, zmiana tokena z jednorazowego na TTL 30 min, redirect przy błędzie tokena na /koszyk-podsumowanie zamiast /koszyk, nowy double-submit guard.
### Phase 14 — Custom fields delete bug
**Problem:** Usunięcie WSZYSTKICH dodatkowych pól z produktu nie działa. jQuery `.serialize()` nie wysyła klucza `custom_field_name[]` gdy nie ma żadnych pól → `array_key_exists('custom_field_name', $d)` w ProductRepository zwraca false → `saveCustomFields()` nigdy nie jest wywoływany → pola pozostają w bazie.
**Scope:** Dodanie hidden markera `custom_field_name_present` w szablonie JS + zmiana warunku w ProductRepository na sprawdzanie tego markera. Test jednostkowy.
### Phase 15 - Scontainers edit saves as new record
**Problem:** Edycja kontenera statycznego (`/admin/scontainers/edit/id={id}`) zapisuje rekord jako nowy wpis zamiast aktualizacji. W praktyce podczas zapisu gubi się `id` i repository wykonuje insert.
**Scope:** Poprawić przekazywanie `id` w nowym flow formularza ScontainersController + dodać test regresyjny dla edycji, bez zmian globalnych w innych kontrolerach.
### Phase 16 - Product list custom labels quick edit
**Problem:** Na liscie produktow brakuje szybkiego trybu uzupelniania `custom_label_0..4`. Administrator musi wchodzic do edycji produktu, co spowalnia uzupelnianie danych Google XML.
**Scope:** Dodac przycisk "Pokaz etykiety niestandardowe" obok "Dodaj produkt", zapisywac jego stan w sesji, pokazac 5 pol custom label pod nazwa produktu, zapisac wartosci do bazy i zapewnic podpowiedzi z juz istniejacych wartosci.
### Phase 17 - Cart summary transport cost fix
**Problem:** Na /koszyk-podsumowanie kazdy transport z flaga `delivery_free = 1` pokazywany jest za 0,00 zl, niezaleznie od tego czy koszyk osiagnal prog darmowej dostawy `settings.free_delivery`. Szablon summary-view.php sprawdza tylko flage, nie wartosc koszyka. Suma koncowa zamowienia jest zaniżona.
**Scope:** Przekazac z `ShopBasketController::summaryView()` do szablonu wyliczony `transport_cost_effective` i flage `free_delivery_applies` uwzgledniajaca prog. Zaktualizowac summary-view.php aby uzywal tych kluczy zamiast surowej flagi `delivery_free`. Test jednostkowy dla logiki wyliczenia.
### Phase 18 — Google feed permutation URL fix
**Problem:** URL produktu z permutacją atrybutów w feedzie Google miał format `/slug/20-170/21-175` (slash między parami). Wzorzec routingu `pp_routes` używa `[0-9-]+`, który nie obejmuje `/`, więc URL nie matchuje żadnej trasy i `index.php` ładuje stronę główną. Klienci z GMC trafiają na home zamiast na produkt z wybraną kombinacją.
**Scope:** Zmienić separator z `/` na `_` w generatorze feedu (`ProductRepository::appendCombinationToXml`), rozszerzyć regex routingu o `_` (`Helpers`), dodać konwersję `_``|` w warstwie front (`LayoutEngine`), preselekcja wartości atrybutu w partialu na podstawie `permutation_hash` z URL. Plus unit testy regex + generator linku.
---
*Last updated: 2026-04-30 (Phase 18 complete)*

View File

@@ -1,37 +1,37 @@
# Specialized Flows: shopPRO
# Specialized Flows: shopPRO
## Project-Level Dependencies
| Work Type | Skill/Command | Priority | Kiedy używać |
| Work Type | Skill/Command | Priority | Kiedy uzywac |
|-----------|---------------|----------|--------------|
| Komponenty UI, szablony widoków | /frontend-design | optional | Przy tworzeniu HTML/CSS |
| Nowe funkcje, większe zmiany | /feature-dev | required | Przed implementacją fazy |
| Przegląd kodu | /code-review | optional | Przed release / KONIEC PRACY |
| Upraszczanie po zmianach | /simplify | optional | Po zakończeniu implementacji |
| Utrzymanie CLAUDE.md | /claude-md-improver | optional | Co kilka faz / po dużych zmianach |
| Release, budowanie update package | /koniec-pracy | required | Na koniec każdej sesji roboczej |
| Zapis i wznowienie sesji | /zapisz + /wznow | optional | Na przerwę / powrót do pracy |
| Komponenty UI, szablony widokow | /frontend-design | optional | Przy tworzeniu HTML/CSS |
| Nowe funkcje, wieksze zmiany | /feature-dev | required | Przed implementacja fazy |
| Przeglad kodu | /code-review | optional | Przed release / KONIEC PRACY |
| Upraszczanie po zmianach | /simplify | optional | Po zakonczeniu implementacji |
| Utrzymanie CLAUDE.md | /claude-md-improver | optional | Co kilka faz / po duzych zmianach |
| Release, budowanie update package | /koniec-pracy | required | Na koniec kazdej sesji roboczej |
| Zapis i wznowienie sesji | /zapisz + /wznow | optional | Na przerwe / powrot do pracy |
## Phase Overrides
Brak domyślna konfiguracja obowiązuje dla wszystkich faz.
Brak - domyslna konfiguracja obowiazuje dla wszystkich faz.
## Templates & Assets
| Asset Type | Location | When Used |
|------------|----------|-----------|
| CLAUDE.md | CLAUDE.md | Konwencje kodu, architektura, stack techniczny |
| Struktura bazy | docs/DATABASE_STRUCTURE.md | Przy zmianach schematu DB |
| Dokumentacja API | api-docs/api-reference.json | Przy zmianach API |
| TODO | docs/TODO.md | Planowanie nowych funkcji |
| Struktura bazy | .paul/docs/DB_SCHEMA.md | Przy zmianach schematu DB |
| Dokumentacja API | .paul/docs/API.md | Przy zmianach API |
| TODO | .paul/docs/TODO.md | Planowanie nowych funkcji |
## Verification (UNIFY)
Podczas UNIFY sprawdź:
- `/feature-dev` czy był użyty przed implementacją fazy?
- `/koniec-pracy` czy release został wykonany?
Podczas UNIFY sprawdz:
- `/feature-dev` - czy byl uzyty przed implementacja fazy?
- `/koniec-pracy` - czy release zostal wykonany?
Braki dokumentuj w STATE.md (Deferred Issues), nie blokują UNIFY.
Braki dokumentuj w STATE.md (Deferred Issues), nie blokuja UNIFY.
---
*SPECIAL-FLOWS.md Created: 2026-03-12*
*SPECIAL-FLOWS.md - Created: 2026-03-12*

View File

@@ -1,47 +1,95 @@
# Project State
# Project State
## Project Reference
See: .paul/PROJECT.md (updated 2026-03-12)
See: .paul/PROJECT.md (updated 2026-04-30)
**Core value:** Właściciel sklepu ma pełną kontrolę nad sprzedażą online w jednym systemie pisanym od podstaw, bez narzutów zewnętrznych platform.
**Current focus:** Hotfix Apilo — COMPLETE
**Current focus:** Phase 18 complete - loop closed
## Current Position
Milestone: Hotfix — Apilo orders not sending
Phase: 8 — Diagnoza i naprawa wysyłki zamówień do Apilo — Complete
Plan: 08-01 complete (phase done)
Status: UNIFY complete, phase 8 finished
Last activity: 2026-03-16 — 08-01 UNIFY complete
Milestone: Hotfix
Milestone: Hotfix
Phase: 18 of 18 (Google feed permutation URL fix) - Complete
Plan: 18-01 complete
Status: UNIFY complete, ready for next PLAN loop (transition-phase git commit pending)
Last activity: 2026-04-30 - Closed loop for .paul/phases/18-google-feed-permutation-url-fix/18-01-PLAN.md
Progress:
- Phase 8: [██████████] 100% (COMPLETE)
- Milestone: [##########] 100% (Hotfix rolling)
- Phase 18: [##########] 100%
## Loop Position
Current loop state (phase 8, plan 01):
Current loop state:
```
PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ [Phase 8 complete]
PLAN --> APPLY --> UNIFY
✓ ✓ ✓ [Loop complete - ready for next PLAN]
```
Previous phases:
```
Phase 4: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE 2026-03-12]
Phase 5: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE 2026-03-12]
Phase 6: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE 2026-03-12]
Phase 7: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE 2026-03-15]
Phase 8: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE 2026-03-16]
Phase 4: PLAN --> APPLY --> UNIFY ✓ ✓ ✓ [COMPLETE - 2026-03-12]
Phase 5: PLAN --> APPLY --> UNIFY ✓ ✓ ✓ [COMPLETE - 2026-03-12]
Phase 6: PLAN --> APPLY --> UNIFY ✓ ✓ ✓ [COMPLETE - 2026-03-12]
Phase 7: PLAN --> APPLY --> UNIFY ✓ ✓ ✓ [COMPLETE - 2026-03-15]
Phase 8: PLAN --> APPLY --> UNIFY ✓ ✓ ✓ [COMPLETE - 2026-03-16]
Phase 9: PLAN --> APPLY --> UNIFY ✓ ✓ ✓ [COMPLETE - 2026-03-19]
Phase 10: PLAN --> APPLY --> UNIFY ✓ ✓ ✓ [COMPLETE - 2026-03-19]
Phase 11: PLAN --> APPLY --> UNIFY ✓ ✓ ✓ [COMPLETE - 2026-03-25]
Phase 12: PLAN --> APPLY --> UNIFY ✓ ✓ ✓ [COMPLETE - 2026-03-25]
Phase 13: PLAN --> APPLY --> UNIFY ✓ ✓ ✓ [COMPLETE - 2026-03-25]
Phase 14: PLAN --> APPLY --> UNIFY ✓ ✓ ✓ [COMPLETE - 2026-04-16]
Phase 15: PLAN --> APPLY --> UNIFY ✓ ✓ ✓ [COMPLETE - 2026-04-18]
Phase 16: PLAN --> APPLY --> UNIFY ✓ ✓ ✓ [COMPLETE - 2026-04-19]
Phase 17: PLAN --> APPLY --> UNIFY ✓ ✓ ✓ [COMPLETE - 2026-04-20]
Phase 18: PLAN --> APPLY --> UNIFY ✓ ✓ ✓ [COMPLETE - 2026-04-30]
```
## Accumulated Context
### Decisions
- 2026-04-30: Phase 18 loop closed with SUMMARY at .paul/phases/18-google-feed-permutation-url-fix/18-01-SUMMARY.md
- 2026-04-30: Transition-phase git commit for Phase 18 not executed in this UNIFY run (deferred — pattern z faz 15/16/17)
- 2026-04-30: Phase 18 APPLY complete — 4 pliki silnika + 2 nowe pliki testów (HelpersRoutingTest 4 testy, ProductFeedLinkTest 3 testy); suita 841 zielona
- 2026-04-30: Created Phase 18 plan at .paul/phases/18-google-feed-permutation-url-fix/18-01-PLAN.md
- 2026-04-30: Phase 18 — separator URL permutacji `/``_`; konwersja `_``|` w warstwie front; regex `[0-9_-]+` w pp_routes
- 2026-04-30: Phase 18 — override /feature-dev (hotfix z konkretną instrukcją), brak redirectów 301, brak automatycznych akcji post-deploy
- 2026-04-20: Phase 17 loop closed with SUMMARY at .paul/phases/17-cart-summary-transport-cost-fix/17-01-SUMMARY.md
- 2026-04-20: Transition-phase git commit for Phase 17 not executed in this UNIFY run (deferred)
- 2026-04-20: Phase 17 APPLY complete - human-verify checkpoint approved, 834 testow zielonych (6 nowych)
- 2026-04-20: Created Phase 17 plan at .paul/phases/17-cart-summary-transport-cost-fix/17-01-PLAN.md
- 2026-04-20: Phase 17 bug root cause - summary-view.php blindly shows 0 zl gdy transport.delivery_free=1 bez sprawdzenia progu settings.free_delivery
- 2026-04-20: Phase 17 fix - nowa chroniona metoda ShopBasketController::calculateTransportCostForSummary zwraca transport_cost_effective + free_delivery_applies; szablon uzywa tych kluczy zamiast delivery_free
- 2026-04-19: Created Phase 16 plan at .paul/phases/16-product-list-custom-labels/16-01-PLAN.md
- 2026-04-19: Phase 16 scope includes session toggle + inline custom_label_0..4 edit + suggestions on product list
- 2026-04-19: Override approved by user - proceeded without required /feature-dev skill in Phase 16 APPLY
- 2026-04-19: /koniec-pracy acknowledged by user as available for session close workflow
- 2026-04-19: Human verify checkpoint approved after UX fixes (button style + autocomplete in single input)
- 2026-04-19: Product list custom labels final UX: single input with autocomplete, no separate select under field
- 2026-04-19: Transition-phase git commit for Phase 16 not executed in this UNIFY run
- 2026-04-18: Transition-phase git commit step pending (not executed during this UNIFY run)
- 2026-04-18: Phase 15 loop closed with SUMMARY at .paul/phases/15-scontainers-edit-save-fix/15-01-SUMMARY.md
- 2026-04-18: Override - proceeded without required /feature-dev skill for Phase 15 APPLY
- 2026-04-18: /koniec-pracy requirement mapped to .claude/commands/koniec-pracy.md guidance for end-of-session release flow
- 2026-04-18: Scontainers edit fix - ID from tabbed form can be omitted when hidden field is defined as FormField and not as hiddenFields
- Use existing `CouponRepository::markAsUsed()` instead of adding methods to stdClass
- 2026-03-16: Przyczyna braku wysyłki = brakujące $apiloRepository w use() closures cron.php (regresja z fazy 6)
- 2026-03-16: Retry -1 orders co 1h zamiast permanent failure
- 2026-03-16: Email notification o trwale failed Apilo jobach
- 2026-03-19: Order-related Apilo joby — infinite retry co 30 min (nigdy permanent failure)
- 2026-03-19: Email z danymi zamówienia + rozróżnienie PONAWIANY vs TRWAŁY BŁĄD
- 2026-03-19: Cleanup stuck sync_payment/sync_status jobów po udanym wysłaniu
- 2026-03-19: Edycja custom fields w koszyku — product_code przeliczany po zmianie, merge duplikatów przy identycznym hashu
- 2026-03-19: JS handlery koszyka w basket.php (nie basket-details.php) bo basket-details jest AJAX-replaceable
- 2026-03-25: view_cart event w basket.php (nie basket-details.php) — ten sam powód
- 2026-03-25: GA4 item format standard: item_id (string), item_name, price (number), quantity (int), google_business_vertical: "retail"
- 2026-03-25: Brak user_data w purchase — wymaga analizy RODO
- 2026-03-25: summaryView() redirect guard usunięty — blokował kolejne zamówienia po pierwszym (z change.md instancji klienta)
- 2026-03-25: Token zamówienia z jednorazowego na TTL 30 min — backward compat z plain string
- 2026-03-25: logOrder() — logowanie błędów zamówień do logs/logs-order-YYYY-MM-DD.log
- 2026-03-25: Redirect przy złym tokenie: /koszyk-podsumowanie zamiast /koszyk
- 2026-04-16: Custom fields delete fix — hidden marker `custom_field_name_present` zamiast `array_key_exists('custom_field_name')`
### Deferred Issues
None.
@@ -49,12 +97,17 @@ None.
### Blockers/Concerns
None.
### Skill Audit (Phase 18)
| Expected | Invoked | Notes |
|----------|---------|-------|
| /feature-dev | ○ | User-approved override (hotfix z konkretną instrukcją) |
| /koniec-pracy | ○ | Pending — uruchomić przy zakończeniu sesji jeśli release wchodzi do update package |
## Session Continuity
Last session: 2026-03-16
Stopped at: Phase 08 UNIFY complete — Apilo fix loop closed
Next action: Deploy fix to instance, then /paul:progress for next work
Resume file: .paul/phases/08-apilo-orders-fix/08-01-SUMMARY.md
Last session: 2026-04-30
Stopped at: Phase 18 complete, loop closed
Next action: Start next phase plan (transition-phase git commit pending), lub uruchomić /koniec-pracy jeśli zamykamy sesję
Resume file: .paul/phases/18-google-feed-permutation-url-fix/18-01-SUMMARY.md
---
*STATE.md — Updated after every significant action*

View File

@@ -0,0 +1,14 @@
# 2026-04-16
## Co zrobiono
- [Phase 14, Plan 01] Fix: usunięcie wszystkich dodatkowych pól produktu nie działało
- Dodano hidden marker `custom_field_name_present` w formularzu edycji produktu
- Zmieniono warunek w ProductRepository na sprawdzanie markera zamiast obecności tablicy pól
- Dodano test jednostkowy testSaveCustomFieldsDeletesAllWhenEmpty
## Zmienione pliki
- `autoload/admin/Controllers/ShopProductController.php`
- `autoload/Domain/Product/ProductRepository.php`
- `tests/Unit/Domain/Product/ProductRepositoryTest.php`

View File

@@ -0,0 +1,13 @@
# 2026-04-18
## Co zrobiono
- [Phase 15, Plan 01] Naprawiono regresje zapisu edycji kontenerow statycznych (update zamiast tworzenia nowego rekordu).
- Przeniesiono przekazywanie `id` w formularzu Scontainers do `hiddenFields` oraz dodano fallback `id` z route parametru.
- Dodano testy regresyjne dla mapowania `id` i create-flow w `ScontainersControllerTest`.
## Zmienione pliki
- `autoload/admin/Controllers/ScontainersController.php`
- `tests/Unit/admin/Controllers/ScontainersControllerTest.php`
- `.paul/phases/15-scontainers-edit-save-fix/15-01-SUMMARY.md`

View File

@@ -0,0 +1,22 @@
# 2026-04-19
## Co zrobiono
- [Phase 16, Plan 01] Dodano szybka edycje custom_label_0..4 w `/admin/shop_product/view_list/` z przelacznikiem sesyjnym.
- Dodano zapis wartosci custom labels do bazy oraz walidacje dozwolonych `label_type` po stronie kontrolera.
- Dodano podpowiedzi istniejacych wartosci jako autocomplete w jednym input (z mozliwoscia wpisania wartosci wlasnej).
- Poprawiono UX przycisku toggla (kolorystyka, rozmiar, hover i czytelnosc).
- Rozszerzono testy jednostkowe kontrolera i repozytorium dla nowej funkcjonalnosci.
## Zmienione pliki
- `autoload/admin/Controllers/ShopProductController.php`
- `autoload/Domain/Product/ProductRepository.php`
- `admin/templates/shop-product/products-list.php`
- `admin/templates/shop-product/products-list-custom-script.php`
- `tests/Unit/admin/Controllers/ShopProductControllerTest.php`
- `tests/Unit/Domain/Product/ProductRepositoryTest.php`
- `.paul/phases/16-product-list-custom-labels/16-01-SUMMARY.md`
- `.paul/PROJECT.md`
- `.paul/ROADMAP.md`
- `.paul/STATE.md`

View File

@@ -0,0 +1,19 @@
# 2026-04-20
## Co zrobiono
- [Phase 17, Plan 01] Naprawa kosztu transportu na /koszyk-podsumowanie — transport z flaga delivery_free=1 pokazuje teraz rzeczywisty koszt ponizej progu settings.free_delivery, a 0,00 zl dopiero po osiagnieciu progu
- Dodana chroniona metoda `ShopBasketController::calculateTransportCostForSummary` wyliczajaca `transport_cost_effective` i `free_delivery_applies`
- Szablon `templates/shop-basket/summary-view.php` uzywa tych kluczy zamiast surowej flagi `delivery_free`
- Nowy plik testow `ShopBasketControllerSummaryViewTest` (6 testow, 12 assertions)
- Pelna suita PHPUnit: 834/834 OK (2318 assertions)
## Zmienione pliki
- `autoload/front/Controllers/ShopBasketController.php`
- `templates/shop-basket/summary-view.php`
- `tests/Unit/front/Controllers/ShopBasketControllerSummaryViewTest.php`
- `.paul/phases/17-cart-summary-transport-cost-fix/17-01-PLAN.md`
- `.paul/phases/17-cart-summary-transport-cost-fix/17-01-SUMMARY.md`
- `.paul/STATE.md`
- `.paul/ROADMAP.md`

View File

@@ -0,0 +1,25 @@
# 2026-04-30
## Co zrobiono
- [Phase 18, Plan 01] Fix linków produktów z permutacją atrybutów w feedzie Google
- Separator URL między parami `attr-val` zmieniony z `/` na `_` w `ProductRepository::appendCombinationToXml`
- Wzorzec routingu `pp_routes` rozszerzony o `_` (`[0-9-]+``[0-9_-]+`) w `Helpers::htacces`
- Konwersja `_``|` w `LayoutEngine` przed wywołaniem `ProductRepository::findCached`
- Preselekcja wartości atrybutu na podstawie `permutation_hash` z URL w partialu `product-attribute.php`
- 2 nowe pliki testów: `HelpersRoutingTest` (4 testy) + `ProductFeedLinkTest` (3 testy via Reflection)
- Suita PHPUnit: 834 → 841 zielonych
## Zmienione pliki
- `autoload/Domain/Product/ProductRepository.php`
- `autoload/Shared/Helpers/Helpers.php`
- `autoload/front/LayoutEngine.php`
- `templates/shop-product/_partial/product-attribute.php`
- `tests/Unit/Shared/Helpers/HelpersRoutingTest.php`
- `tests/Unit/Domain/Product/ProductFeedLinkTest.php`
- `.paul/STATE.md`
- `.paul/PROJECT.md`
- `.paul/ROADMAP.md`
- `.paul/phases/18-google-feed-permutation-url-fix/18-01-PLAN.md`
- `.paul/phases/18-google-feed-permutation-url-fix/18-01-SUMMARY.md`

View File

@@ -1,11 +1,11 @@
# Testing Patterns
# Testing Patterns
## Overview
| Metric | Value |
|--------|-------|
| Total tests | **810** |
| Total assertions | **2264** |
| Total tests | **841** |
| Total assertions | **2330** |
| Framework | PHPUnit 9.6 (`phpunit.phar`) |
| Bootstrap | `tests/bootstrap.php` |
| Config | `phpunit.xml` |
@@ -13,7 +13,7 @@
## Running Tests
```bash
# Full suite (PowerShell recommended)
# Full suite (PowerShell — recommended)
./test.ps1
# Specific file
@@ -36,16 +36,16 @@ Tests mirror source structure:
```
tests/Unit/
├── Domain/
│ ├── Product/ProductRepositoryTest.php
│ ├── Category/CategoryRepositoryTest.php
│ ├── Order/OrderRepositoryTest.php
│ └── ... (all 29 modules covered)
├── admin/Controllers/
│ ├── ShopCategoryControllerTest.php
│ └── ...
└── api/
└── ...
├── Domain/
│ ├── Product/ProductRepositoryTest.php
│ ├── Category/CategoryRepositoryTest.php
│ ├── Order/OrderRepositoryTest.php
│ └── ... (all 29 modules covered)
├── admin/Controllers/
│ ├── ShopCategoryControllerTest.php
│ └── ...
└── api/
└── ...
```
## Test Class Pattern
@@ -229,13 +229,13 @@ $this->assertInstanceOf(ClassName::class, $obj);
## What's Covered
- All 29 Domain repositories
- Core business logic (quantity, pricing, category tree)
- Query behavior with mocked Medoo
- Cache patterns
- Controller constructor injection
- `FormValidator` behavior
- API controllers
- All 29 Domain repositories âś“
- Core business logic (quantity, pricing, category tree) âś“
- Query behavior with mocked Medoo âś“
- Cache patterns âś“
- Controller constructor injection âś“
- `FormValidator` behavior âś“
- API controllers âś“
## What's Lightly Covered
@@ -243,3 +243,4 @@ $this->assertInstanceOf(ClassName::class, $obj);
- Session state in tests
- AJAX response integration
- Frontend Views (static classes)

255
.paul/docs/API.md Normal file
View File

@@ -0,0 +1,255 @@
# API
## Scope
Dokument opisuje aktualne REST API dostepne przez `api.php` (ordersPRO + slowniki + produkty + kategorie).
## Base URL
- Endpoint techniczny: `/api.php`
- Routing odbywa sie przez query params:
- `endpoint` (np. `orders`, `products`)
- `action` (np. `list`, `get`)
Przyklad:
```text
GET /api.php?endpoint=orders&action=list
```
## Authentication
- Wymagany naglowek: `X-Api-Key: <api_key>`
- Klucz jest porownywany z wartoscia `pp_settings.api_key`
- Brak lub zly klucz:
- HTTP `401`
- payload:
```json
{
"status": "error",
"code": "UNAUTHORIZED",
"message": "Invalid or missing API key"
}
```
## Response format
Sukces:
```json
{
"status": "ok",
"data": {}
}
```
Blad:
```json
{
"status": "error",
"code": "BAD_REQUEST",
"message": "..."
}
```
## Common HTTP/logic errors
- `400 BAD_REQUEST` - brak wymaganych parametrow/body
- `401 UNAUTHORIZED` - brak/zly API key
- `404 NOT_FOUND` - nieznany endpoint/action lub brak rekordu
- `405 METHOD_NOT_ALLOWED` - zla metoda HTTP
- `500 INTERNAL_ERROR` - blad serwera
## Endpoints
### Orders (`endpoint=orders`)
- `GET action=list`
- filtry (opcjonalne): `status`, `paid`, `date_from`, `date_to`, `updated_since`, `number`, `client`
- paginacja: `page` (default 1), `per_page` (default 50, max 100)
- `GET action=get&id={id}`
- `PUT action=change_status&id={id}`
- body JSON:
- `status_id` (required, int)
- `send_email` (optional, bool)
- `PUT action=set_paid&id={id}`
- body JSON optional: `send_email` (bool)
- `PUT action=set_unpaid&id={id}`
### Products (`endpoint=products`)
- `GET action=list`
- filtry:
- `search`, `status`, `promoted`
- `attribute_{attributeId}={valueId}` (np. `attribute_12=37`)
- sortowanie: `sort` (default `id`), `sort_dir` (default `DESC`)
- paginacja: `page`, `per_page` (max 100)
- `GET action=get&id={id}`
- `POST action=create`
- wymagane minimum:
- `languages` (array, co najmniej jeden jezyk z `name`)
- `price_brutto` (number >= 0)
- `PUT action=update&id={id}`
- partial update przez JSON body
- `GET action=variants&id={parentProductId}`
- dla produktu glownego (nie wariantu)
- `POST action=create_variant&id={parentProductId}`
- body:
- `attributes` (required array)
- opcjonalnie pola wariantu (np. `price_brutto`, `quantity`, `sku`, ...)
- `PUT action=update_variant&id={variantId}`
- partial update wariantu
- `DELETE action=delete_variant&id={variantId}`
- `POST action=upload_image`
- body:
- `id` (product id, required)
- `file_name` (required)
- `content_base64` (required)
- `alt` (optional)
- `o` (optional position)
### Dictionaries (`endpoint=dictionaries`)
- `GET action=statuses`
- `GET action=transports`
- `GET action=payment_methods`
- `GET action=attributes`
- `POST action=ensure_attribute`
- body: `name` (required), `type` (optional int), `lang` (optional, default `pl`)
- `POST action=ensure_attribute_value`
- body: `attribute_id` (required), `name` (required), `lang` (optional, default `pl`)
- `POST action=ensure_producer`
- body: `name` (required)
### Categories (`endpoint=categories`)
- `GET action=list`
- zwraca aktywne kategorie w formie flat list:
- `id`
- `parent_id`
- `title`
- tytuly pobierane najpierw w jezyku domyslnym (`pp_langs.start=1`), potem fallback.
## Source of truth (mapa API w kodzie)
### 1) Wejscie i dispatch
- `api.php`
- wykrywa request API przez `$_GET['endpoint']`
- ustawia JSON content-type
- tworzy `medoo` + `SettingsRepository`
- przekazuje sterowanie do `\api\ApiRouter::handle()`
- `autoload/api/ApiRouter.php`
- autentykacja (`X-Api-Key` vs `pp_settings.api_key`)
- walidacja `endpoint` i `action`
- mapowanie endpoint -> kontroler (`getControllerFactories()`)
- helpery odpowiedzi: `sendSuccess()`, `sendError()`
- helpery requestu: `getJsonBody()`, `requireMethod()`
### 2) Endpointy i kontrolery (runtime source)
#### `endpoint=orders`
- plik: `autoload/api/Controllers/OrdersApiController.php`
- akcje:
- `list` (GET)
- `get` (GET)
- `change_status` (PUT)
- `set_paid` (PUT)
- `set_unpaid` (PUT)
#### `endpoint=products`
- plik: `autoload/api/Controllers/ProductsApiController.php`
- akcje:
- `list` (GET)
- `get` (GET)
- `create` (POST)
- `update` (PUT)
- `variants` (GET)
- `create_variant` (POST)
- `update_variant` (PUT)
- `delete_variant` (DELETE)
- `upload_image` (POST)
#### `endpoint=dictionaries`
- plik: `autoload/api/Controllers/DictionariesApiController.php`
- akcje:
- `statuses` (GET)
- `transports` (GET)
- `payment_methods` (GET)
- `attributes` (GET)
- `ensure_attribute` (POST)
- `ensure_attribute_value` (POST)
- `ensure_producer` (POST)
#### `endpoint=categories`
- plik: `autoload/api/Controllers/CategoriesApiController.php`
- akcje:
- `list` (GET)
### 3) Warstwa domenowa pod API (shape danych)
- Orders:
- `Domain\Order\OrderRepository::listForApi()`
- `Domain\Order\OrderRepository::findForApi()`
- `Domain\Order\OrderAdminService` (zmiana statusu/platnosci)
- Products:
- `Domain\Product\ProductRepository::listForApi()`
- `Domain\Product\ProductRepository::findForApi()`
- `Domain\Product\ProductRepository::saveProduct()`
- metody wariantow `*VariantForApi()`
- Dictionaries:
- `Domain\ShopStatus\ShopStatusRepository`
- `Domain\Transport\TransportRepository`
- `Domain\PaymentMethod\PaymentMethodRepository`
- `Domain\Attribute\AttributeRepository`
- `Domain\Producer\ProducerRepository`
- Categories:
- bezposrednio query przez `$GLOBALS['mdb']` w `CategoriesApiController`
### 4) Testy API (behavior source)
- `tests/Unit/api/ApiRouterTest.php`
- `tests/Unit/api/Controllers/OrdersApiControllerTest.php`
- `tests/Unit/api/Controllers/ProductsApiControllerTest.php`
- `tests/Unit/api/Controllers/DictionariesApiControllerTest.php`
### 5) Dokumentacja kontraktu (human source)
- `api-docs/api-reference.json`
- `api-docs/index.html`
Uwaga: dokumentacja z `api-docs/*` moze byc starsza od runtime.
Zrodlem prawdy dla dzialania endpointow jest zawsze:
`api.php` + `autoload/api/ApiRouter.php` + aktualne kontrolery `autoload/api/Controllers/*`.
## Curl examples
Pobranie listy zamowien:
```bash
curl -X GET "https://example.com/api.php?endpoint=orders&action=list&page=1&per_page=20" \
-H "X-Api-Key: YOUR_API_KEY"
```
Zmiana statusu zamowienia:
```bash
curl -X PUT "https://example.com/api.php?endpoint=orders&action=change_status&id=123" \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"status_id\": 6, \"send_email\": true}"
```
Dodanie wariantu produktu:
```bash
curl -X POST "https://example.com/api.php?endpoint=products&action=create_variant&id=50" \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"attributes\":[{\"attribute_id\":12,\"attribute_value_id\":37}],\"price_brutto\":99.99,\"quantity\":10}"
```

192
.paul/docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,192 @@
# ARCHITECTURE
## Scope
Dokument opisuje aktualna architekture runtime projektu `shopPRO`:
- warstwy aplikacji i ich odpowiedzialnosci,
- przeplywy requestow (admin/front/api),
- Dependency Injection i miejsca wiringu,
- konwencje autoloadera i namespace,
- granice miedzy nowa architektura a pozostalosciami legacy.
## High-level layout
Kod aplikacji jest podzielony na 4 glowne warstwy:
1. `autoload/Domain/` - logika biznesowa i dostep do danych (28 modulow)
2. `autoload/admin/` - panel administracyjny (router + kontrolery + form system)
3. `autoload/front/` - frontend sklepu (router + layout engine + kontrolery/widoki)
4. `autoload/api/` - REST API (`api.php`, `ApiRouter`, kontrolery endpointow)
Komponenty wspoldzielone sa trzymane w `autoload/Shared/`.
## Directory map (runtime)
```text
autoload/
Domain/ # 28 modulow domenowych
Shared/ # Cache, Helpers, Tpl, Html, Email, Image
admin/
App.php # routing + DI factories
Controllers/ # 28 kontrolerow admin
Support/ # formularze/tabele
Validation/ # walidacja formularzy
ViewModels/ # modele widokow
front/
App.php # routing + DI factories + fallback legacy
LayoutEngine.php # silnik layoutu frontend
Controllers/ # 8 kontrolerow frontend
Views/ # 11 statycznych klas widokow
api/
ApiRouter.php # auth + endpoint dispatch
Controllers/ # 4 kontrolery API
```
## Entry points i przeplyw
### Admin (`admin/index.php`)
1. Bootstrap (sesja, DB, autoload)
2. `admin\App::update()` - uruchamia pending migracje
3. `admin\App::special_actions()` - logowanie/wylogowanie/2FA
4. `admin\App::render()`:
- auth gate (lub formularz logowania),
- `route()` -> kontroler + akcja,
- render przez `Shared\Tpl\Tpl`.
### Front (`index.php`)
1. Bootstrap (sesja, DB, autoload, jezyk)
2. Mapowanie URL (redirecty + routes)
3. `front\App::checkUrlParams()`
4. `front\App::route()`:
- artykul/produkt/kategoria,
- nowe kontrolery DI,
- fallback do `front\controls\*` (legacy, jesli istnieje)
5. `front\LayoutEngine::show()` sklada finalny HTML.
### API (`api.php`)
1. Bootstrap (bez sesji biznesowej)
2. Tworzenie `\api\ApiRouter`
3. `ApiRouter::handle()`:
- auth przez `X-Api-Key`,
- walidacja `endpoint` i `action`,
- dispatch do kontrolera API,
- JSON response przez `sendSuccess()` / `sendError()`.
Szczegolowa specyfikacja endpointow: `.paul/docs/API.md`.
## Dependency Injection (manual factories)
DI jest realizowane recznie w mapach factory:
- admin: `autoload/admin/App.php` -> `getControllerFactories()`
- front: `autoload/front/App.php` -> `getControllerFactories()`
- api: `autoload/api/ApiRouter.php` -> `getControllerFactories()`
Wzorzec:
- router tworzy repozytoria domenowe,
- repozytoria sa wstrzykiwane do kontrolerow przez konstruktor,
- kontroler wywoluje metody domenowe i zwraca HTML/JSON.
## Autoloader i namespace rules
Kazdy entry point korzysta z custom autoloadera:
1. `autoload/{namespace}/class.{ClassName}.php` (legacy)
2. `autoload/{namespace}/{ClassName}.php` (nowy format)
Mapowanie namespace -> katalog (case-sensitive na Linux):
- `\Domain\` -> `autoload/Domain/`
- `\Shared\` -> `autoload/Shared/`
- `\admin\` -> `autoload/admin/` (male `a`)
- `\front\` -> `autoload/front/`
- `\api\` -> `autoload/api/`
Nie uzywac `\Admin\` (duze `A`), bo katalog runtime to `admin/`.
## Domain layer (28 modulow)
Aktualne moduly:
`Article`, `Attribute`, `Banner`, `Basket`, `Cache`, `Category`, `Client`, `Coupon`, `CronJob`, `Dashboard`, `Dictionaries`, `Integrations`, `Languages`, `Layouts`, `Newsletter`, `Order`, `Pages`, `PaymentMethod`, `Producer`, `Product`, `ProductSet`, `Promotion`, `Scontainers`, `Settings`, `ShopStatus`, `Transport`, `Update`, `User`.
Zasada: logika biznesowa i dostep do danych sa w Domain, bez duplikowania osobnych warstw "admin service" i "front service" dla tych samych przypadkow (wyjatki tylko tam, gdzie historycznie juz istnieja, np. `OrderAdminService`).
## Admin architecture
- Router: `admin\App`
- Kontrolery: `autoload/admin/Controllers/*.php` (28 klas)
- Form system:
- `admin\ViewModels\Forms\*`
- `admin\Support\Forms\FormRequestHandler`
- `admin\Support\Forms\FormFieldRenderer`
- `admin\Validation\FormValidator`
- template: `admin/templates/components/form-edit.php`
Admin ma pelne DI i nie korzysta z fallbacku na legacy kontrolery.
## Front architecture
- Router: `front\App`
- Layout engine: `front\LayoutEngine`
- Kontrolery DI: `autoload/front/Controllers/*.php` (8 klas)
- Widoki statyczne: `autoload/front/Views/*.php` (11 klas)
Wazne: frontend nadal ma fallback do `\front\controls\*`, wiec architektura jest hybrydowa (new DI + remaining legacy paths).
## API architecture
- Router: `api\ApiRouter`
- Endpointy: `orders`, `products`, `dictionaries`, `categories`
- Kontrolery: `autoload/api/Controllers/*ApiController.php` (4 klasy)
- API jest stateless, autoryzowane naglowkiem `X-Api-Key`
Source-of-truth API to runtime:
- `api.php`
- `autoload/api/ApiRouter.php`
- `autoload/api/Controllers/*`
## Shared components
Najwazniejsze klasy wspoldzielone:
- `Shared\Cache\CacheHandler`, `Shared\Cache\RedisConnection`
- `Shared\Helpers\Helpers`
- `Shared\Tpl\Tpl`
- `Shared\Html\Html`
- `Shared\Email\Email`
- `Shared\Image\ImageManipulator`
## Data and cache conventions
- ORM: Medoo (`$mdb`)
- Prefix tabel: `pp_`
- Cache: Redis, domyslnie TTL `86400`
- Dane cache czesto serializowane (`serialize`/`unserialize`)
- Czyszczenie cache produktu: pattern `shop\\product:{id}:*`
## Security boundaries
- Admin:
- sesja uzytkownika admina,
- CSRF token w akcjach POST,
- 2FA email flow (pending session + verify).
- API:
- `X-Api-Key` porownywany przez `hash_equals()`,
- brak logiki sesyjnej.
- Front:
- sesja klienta + walidacja przeplywow frontendowych.
## Source files
Najwazniejsze pliki do szybkiej orientacji:
- `autoload/admin/App.php`
- `autoload/front/App.php`
- `autoload/front/LayoutEngine.php`
- `autoload/api/ApiRouter.php`
- `.paul/docs/API.md`
- `docs/PROJECT_STRUCTURE.md`

228
.paul/docs/DB_SCHEMA.md Normal file
View File

@@ -0,0 +1,228 @@
# DB_SCHEMA
## Scope
Dokument opisuje praktyczny schema map dla `shopPRO`:
- najwazniejsze tabele i relacje,
- grupowanie po domenach biznesowych,
- kluczowe kolumny i indeksy, ktore maja znaczenie runtime,
- mapowanie tabela -> warstwa Domain.
Pelna lista tabel i historyczne notki migracyjne:
`docs/DATABASE_STRUCTURE.md` (source of truth dla detali kolumnowych).
## Konwencje globalne
- ORM: Medoo (`$mdb`)
- Prefix tabel: `pp_`
- Primary key: najczesciej `id` (INT AUTO_INCREMENT)
- Jezyki/translations: zwykle tabele `*_langs` z kluczem `lang_id`
- Wiele-do-wielu: tabele lacznikowe `*_products`, `*_payment_methods`, itp.
## Core commerce
### Produkty
- `pp_shop_products`
- core produktu i wariantu (`parent_id` dla kombinacji)
- ceny (`price_brutto`, `price_brutto_promo`), stany (`quantity`)
- flagi (`status`, `archive`, `promoted`)
- `pp_shop_products_langs`
- nazwy/opisy per jezyk
- `pp_shop_products_images`
- obrazy produktu
- `pp_shop_products_categories`
- przypisania produkt-kategoria
- `pp_shop_products_attributes`
- przypisania wariantu do wartosci cech
- `pp_shop_products_custom_fields`
- dodatkowe pola produktu
Warstwa: `Domain\Product\ProductRepository`, `Domain\Attribute\AttributeRepository`.
### Kategorie
- `pp_shop_categories`
- drzewo kategorii (`parent_id`), status, kolejnosc
- `pp_shop_categories_langs`
- tresci SEO i opisy kategorii
Warstwa: `Domain\Category\CategoryRepository`.
### Zamowienia
- `pp_shop_orders`
- dane klienta "w momencie zakupu", summary, status/platnosc, daty
- kluczowe pole integracyjne: `updated_at` (polling API)
Warstwa: `Domain\Order\OrderRepository`, `Domain\Order\OrderAdminService`.
### Klienci
- `pp_shop_clients`
- konto klienta i dane adresowe/logowania (uzywane przez ClientRepository)
Warstwa: `Domain\Client\ClientRepository`.
## Slowniki i checkout
### Platnosci
- `pp_shop_payment_methods`
- status, opis, mapowanie Apilo
- limity kwotowe: `min_order_amount`, `max_order_amount`
- COD flag: `is_cod`
Warstwa: `Domain\PaymentMethod\PaymentMethodRepository`.
### Transport
- `pp_shop_transports`
- koszt, status, limity, mapowanie Apilo
- `pp_shop_transport_payment_methods`
- relacja transport <-> platnosc (N:M)
Warstwa: `Domain\Transport\TransportRepository`.
### Statusy zamowien
- `pp_shop_statuses`
- statusy predefiniowane, kolor, mapowanie Apilo
Warstwa: `Domain\ShopStatus\ShopStatusRepository`.
## Marketing i merch
### Promocje i kupony
- `pp_shop_promotion`
- reguly promocji, daty aktywnosci, warunki i zakresy (JSON categories)
- `pp_shop_coupon`
- kupony, licznik uzyc, ograniczenia
Warstwa: `Domain\Promotion\PromotionRepository`, `Domain\Coupon\CouponRepository`.
### Producenci
- `pp_shop_producer`
- `pp_shop_producer_lang`
Warstwa: `Domain\Producer\ProducerRepository`.
### Zestawy produktow
- `pp_shop_product_sets`
- `pp_shop_product_sets_products`
Warstwa: `Domain\ProductSet\ProductSetRepository`.
### Cechy i wartosci
- `pp_shop_attributes`
- `pp_shop_attributes_langs`
- `pp_shop_attributes_values`
- `pp_shop_attributes_values_langs`
Warstwa: `Domain\Attribute\AttributeRepository`.
## CMS i frontend content
### Artykuly
- `pp_articles`
- `pp_articles_langs`
- `pp_articles_pages`
- `pp_articles_images`
- `pp_articles_files`
Warstwa: `Domain\Article\ArticleRepository`.
### Strony i layouty
- `pp_pages`
- `pp_layouts`
- `pp_layouts_pages`
- `pp_layouts_categories`
Warstwa: `Domain\Pages\PagesRepository`, `Domain\Layouts\LayoutsRepository`.
### Banery i kontenery statyczne
- `pp_banners`
- `pp_banners_langs`
- `pp_scontainers`
- `pp_scontainers_langs`
Warstwa: `Domain\Banner\BannerRepository`, `Domain\Scontainers\ScontainersRepository`.
## Ustawienia i system
### Ustawienia aplikacji
- `pp_settings`
- klucze globalne (w tym `api_key` dla REST API)
- `pp_shop_apilo_settings`
- `pp_shop_shoppro_settings`
Warstwa: `Domain\Settings\SettingsRepository`, `Domain\Integrations\IntegrationsRepository`.
### Jezyki i tlumaczenia
- `pp_langs`
- `pp_langs_translations`
Warstwa: `Domain\Languages\LanguagesRepository`.
### Uzytkownicy admina
- `pp_users`
- login, hash hasla, status
- pola 2FA (`twofa_*`)
Warstwa: `Domain\User\UserRepository`.
## Routing i URL mapping
- `pp_routes`
- regex `pattern` -> `destination` query string
- obsluguje trasy encji oraz trasy systemowe
- cache Redis: `pp_routes:all`
Runtime wykorzystanie:
- `index.php`
- `Shared\Helpers\Helpers::htacces()`
- repozytoria encji generujace/odswiezajace trasy.
## Kolejka cron
- `pp_cron_jobs`
- status processing pipeline (`pending`, `processing`, `completed`, `failed`, `cancelled`)
- retry/backoff: `attempts`, `max_attempts`, `scheduled_at`
- indeksy:
- `(status, priority, scheduled_at)`
- `(job_type)`
- `(status)`
- `pp_cron_schedules`
- harmonogramy okresowe (`interval_seconds`, `next_run_at`)
- indeks `(enabled, next_run_at)`
Warstwa: `Domain\CronJob\CronJobRepository`, `Domain\CronJob\CronJobProcessor`.
## Najwazniejsze relacje (FK logiczne)
- Produkt glowny -> wariant: `pp_shop_products.parent_id -> pp_shop_products.id`
- Produkt -> tlumaczenia: `pp_shop_products_langs.product_id -> pp_shop_products.id`
- Produkt -> kategoria: `pp_shop_products_categories.product_id -> pp_shop_products.id`
- Kategoria -> tlumaczenia: `pp_shop_categories_langs.category_id -> pp_shop_categories.id`
- Zamowienie -> klient: `pp_shop_orders.client_id -> pp_shop_clients.id` (opcjonalne)
- Transport <-> platnosc: `pp_shop_transport_payment_methods`
- Cecha -> wartosci -> warianty: `attributes -> values -> shop_products_attributes`
- Producent -> tlumaczenia: `pp_shop_producer_lang.producer_id -> pp_shop_producer.id`
- Kontener -> tlumaczenia: `pp_scontainers_langs.container_id -> pp_scontainers.id`
## Uwaga operacyjna
Ten dokument jest skrotem architektonicznym.
Przy zmianach SQL/migracji zawsze aktualizuj rownolegle:
1. `docs/DATABASE_STRUCTURE.md` (detal techniczny)
2. `.paul/docs/DB_SCHEMA.md` (mapa domenowa i impact runtime)

3
.paul/docs/DECISIONS.md Normal file
View File

@@ -0,0 +1,3 @@
# DECISIONS
> Kluczowe decyzje techniczne i ich uzasadnienia.

3
.paul/docs/STACK.md Normal file
View File

@@ -0,0 +1,3 @@
# STACK
> Stack technologiczny, wersje, zaleznosci, srodowisko.

View File

@@ -0,0 +1,29 @@
# TECH_CHANGELOG
> Chronologiczny log zmian technicznych — co i dlaczego.
## v0.350 (2026-04-30)
- Naprawiono linki produktow z permutacja atrybutow w feedzie Google: separator par `attr-val` w URL zmieniony z `/` na `_`. Stary format `/slug/20-170/21-175` nie matchowal sie w `pp_routes` (regex `[0-9-]+` nie obejmuje `/`), wiec klienci z GMC ladowali na strone glowna zamiast na produkt.
- `ProductRepository::appendCombinationToXml`: `str_replace('|', '/', ...)` -> `str_replace('|', '_', ...)` w obu galeziach (z `seo_link` i fallback `p-id-name`).
- `Helpers::htacces`: regex routingu produktow z permutacja rozszerzony do `/([0-9_-]+)$` w obu wariantach.
- `LayoutEngine.php` (// PRODUKT): konwersja `_` -> `|` przed wywolaniem `ProductRepository::findCached` — format DB pozostaje bez zmian (`attr-val|attr-val`).
- `templates/shop-product/_partial/product-attribute.php`: preselekcja wartosci atrybutu na podstawie `permutation_hash` z URL (`$forced_value_id`); wartosc `is_default` uzywana tylko gdy URL nie wymusza wyboru. Dotyczy `checked` na inpucie i emisji bloku `fradio_label_click(...)`.
- Dodano 7 testow jednostkowych: `HelpersRoutingTest` (4 testy regex + assercje na zawartosci pliku) i `ProductFeedLinkTest` (3 testy `appendCombinationToXml` via `ReflectionMethod` z mockiem `TransportRepository`). Suita: 841 testow / 2330 assertions.
- Wymagane akcje na produkcji po deployu: regeneracja `pp_routes` (`Helpers::htacces()`), wyczyszczenie klucza `pp_routes:all` w Redis, regeneracja `google-feed.xml`, resubmit feedu w GMC.
## v0.349 (2026-04-20)
- Naprawiono wyswietlanie kosztu transportu na /koszyk-podsumowanie: transporty z `delivery_free=1` pokazuja teraz rzeczywisty koszt ponizej progu `settings.free_delivery`, a 0,00 zl dopiero po osiagnieciu progu (spojnie z lista na /koszyk).
- Dodano chroniona metode `ShopBasketController::calculateTransportCostForSummary()` wyliczajaca `transport_cost_effective` + `free_delivery_applies` — logika widokowa przeniesiona z szablonu do kontrolera.
- Szablon `templates/shop-basket/summary-view.php` uzywa nowych kluczy zamiast sprawdzania surowej flagi `delivery_free`.
- Dodano 6 testow jednostkowych (`ShopBasketControllerSummaryViewTest`) pokrywajacych AC + edge cases (prog rowny, prog 0, transport null). Suita: 834 testy / 2318 assertions.
## v0.348 (2026-04-19)
- Dodano przełącznik widoczności etykiet niestandardowych na liście produktów w panelu admina, z zapisem stanu w sesji.
- Po włączeniu opcji renderowane jest 5 pól custom_label_0..4 bezpośrednio pod sekcją zdjęcie/nazwa produktu.
- Dodano zapisywanie wartości etykiet niestandardowych do bazy oraz walidację dozwolonych typów etykiet po stronie kontrolera.
- Wprowadzono podpowiedzi istniejących wartości jako wybieralne sugestie z możliwością wpisania własnej wartości.
- Rozszerzono testy jednostkowe dla ShopProductController i ProductRepository pod nową funkcjonalność.

3693
.paul/docs/TODO.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
{"ts":"2026-04-16T09:36:18Z","tool":"Bash","cmd":"mkdir -p \"C:/visual studio code/projekty/shopPRO/.paul/phases/14-custom-fields-delete-bug\"\",\"description\":\"Create phase 14 directory\"},\"tool_response\":{\"stdout\":\"\",\"stderr\":\"\",\"inter","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T09:36:54Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\phases\\\\14-custom-fields-delete-bug\\\\14-01-PLAN.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T09:37:27Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T09:37:31Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T09:37:38Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T09:55:40Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\autoload\\\\admin\\\\Controllers\\\\ShopProductController.php","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T09:55:47Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\autoload\\\\Domain\\\\Product\\\\ProductRepository.php","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T09:56:01Z","tool":"Bash","cmd":"wc -l \"C:/visual studio code/projekty/shopPRO/tests/Unit/Domain/Product/ProductRepositoryTest.php\"\",\"description\":\"Count lines in test file\"},\"tool_response\":{\"stdout\":\"1295 C:/visual stud","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T09:56:13Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\tests\\\\Unit\\\\Domain\\\\Product\\\\ProductRepositoryTest.php","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T09:56:20Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && php phpunit.phar --filter testSaveCustomFieldsDeletesAllWhenEmpty 2>&1\",\"description\":\"Run specific unit test\"},\"tool_response\":{\"stdout\":\"PH","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T09:56:27Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && php phpunit.phar 2>&1 | tail -5\",\"description\":\"Run full test suite, show summary\"},\"tool_response\":{\"stdout\":\".. ","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T09:57:00Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T20:45:27Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\phases\\\\14-custom-fields-delete-bug\\\\14-01-SUMMARY.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T20:45:38Z","tool":"Bash","cmd":"mkdir -p \"C:/visual studio code/projekty/shopPRO/.paul/changelog\"\",\"description\":\"Create changelog directory\"},\"tool_response\":{\"stdout\":\"\",\"stderr\":\"\",\"interrupted\":false,\"isImage","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T20:45:45Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\changelog\\\\2026-04-16.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T20:46:16Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T20:46:21Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T20:50:25Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && php phpunit.phar 2>&1 | tail -5\",\"description\":\"Run full PHPUnit test suite\"},\"tool_response\":{\"stdout\":\".. ","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T20:50:35Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && git tag --sort=-v:refname | head -3\",\"description\":\"Get latest version tags\"},\"tool_response\":{\"stdout\":\"v0.345\\nv0.344\\nv0.343\",\"stderr\","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T20:51:07Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\CLAUDE.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T20:51:31Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\docs\\\\TESTING.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T20:51:37Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\PROJECT.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T20:51:43Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && git status -s\",\"description\":\"Show working tree status\"},\"tool_response\":{\"stdout\":\" M .paul/PROJECT.md\\n M .paul/ROADMAP.md\\n M .paul/STAT","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T20:51:50Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && git add autoload/Domain/Product/ProductRepository.php autoload/admin/Controllers/ShopProductController.php tests/Unit/Domain/Product/ProductRepositoryT","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T20:51:58Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && git commit -m \"$(cat <<'EOF'\\nfix: Custom fields delete bug — usunięcie wszystkich pól produktu nie działało\\n\\nDodano hidden marker custom_f","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T20:52:14Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && git push 2>&1\",\"description\":\"Retry git push\"},\"tool_response\":{\"stdout\":\"remote: . Processing 1 references \\nremote: Processed 1 ref","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T20:52:25Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && git tag v0.346 && powershell.exe -ExecutionPolicy Bypass -File build-update.ps1 -FromTag v0.345 -ToTag v0.346 -ChangelogEntry \"Fix usuwania wszystkich","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T20:52:32Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && git add updates/0.30/ver_0.346.zip updates/0.30/ver_0.346_manifest.json updates/versions.php updates/changelog-data.html && git commit -m \"$(cat <<'EO","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-16T20:52:43Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && git push 2>&1 && git push origin v0.346 2>&1\",\"description\":\"Push commit and tag\"},\"tool_response\":{\"stdout\":\"remote: . Processing 1 referenc","cwd":"/c/visual studio code/projekty/shopPRO"}

View File

@@ -0,0 +1,46 @@
{"ts":"2026-04-20T18:30:03Z","tool":"Bash","cmd":"mkdir -p \"C:/visual studio code/projekty/shopPRO/.paul/phases/17-cart-summary-transport-cost-fix\"\",\"description\":\"Create phase 17 directory\"},\"tool_response\":{\"stdout\":\"\",\"stderr\":\"\",","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:31:17Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\phases\\\\17-cart-summary-transport-cost-fix\\\\17-01-PLAN.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:31:29Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:31:34Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:31:40Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:31:45Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:31:52Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:32:07Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:33:13Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\autoload\\\\front\\\\Controllers\\\\ShopBasketController.php","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:33:24Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\templates\\\\shop-basket\\\\summary-view.php","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:34:04Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\tests\\\\Unit\\\\front\\\\Controllers\\\\ShopBasketControllerSummaryViewTest.php","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:34:09Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && powershell -ExecutionPolicy Bypass -File ./test.ps1 tests/Unit/front/Controllers/ShopBasketControllerSummaryViewTest.php 2>&1 | tail -30\",\"timeout\":","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:34:15Z","tool":"Bash","cmd":"powershell -ExecutionPolicy Bypass -File \"C:/visual studio code/projekty/shopPRO/test.ps1\" tests/Unit/front/Controllers/ShopBasketControllerSummaryViewTest.php 2>&1 | tail -40\",\"timeout\":120000,\","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:34:32Z","tool":"Bash","cmd":"where php 2>&1 | head -3\",\"description\":\"Locate PHP binary\"},\"tool_response\":{\"stdout\":\"C:\\\\xampp\\\\php\\\\php.exe\",\"stderr\":\"\",\"interrupted\":false,\"isImage\":false,\"noOutputExpe","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:34:39Z","tool":"Bash","cmd":"\"C:/xampp/php/php.exe\" \"C:/visual studio code/projekty/shopPRO/phpunit.phar\" -c \"C:/visual studio code/projekty/shopPRO/phpunit.xml\" \"C:/visual studio code/projekty/shopPRO/tests/Unit/front/Con","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:34:45Z","tool":"Bash","cmd":"\"C:/xampp/php/php.exe\" \"C:/visual studio code/projekty/shopPRO/phpunit.phar\" -c \"C:/visual studio code/projekty/shopPRO/phpunit.xml\" 2>&1 | tail -15\",\"timeout\":300000,\"description\":\"Run fu","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:36:07Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:36:12Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:36:19Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:36:27Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:42:55Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\phases\\\\17-cart-summary-transport-cost-fix\\\\17-01-SUMMARY.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:43:09Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\changelog\\\\2026-04-20.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:43:21Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:43:25Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:43:31Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:43:36Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:43:40Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:43:45Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:43:51Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\PROJECT.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:43:55Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\PROJECT.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:44:02Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\PROJECT.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:44:05Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\PROJECT.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:44:12Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\PROJECT.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:46:36Z","tool":"Bash","cmd":"\"C:/xampp/php/php.exe\" \"C:/visual studio code/projekty/shopPRO/phpunit.phar\" -c \"C:/visual studio code/projekty/shopPRO/phpunit.xml\" 2>&1 | tail -5\",\"timeout\":300000,\"description\":\"Run ful","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:48:13Z","tool":"Bash","cmd":"cd \"/c/visual studio code/projekty/shopPRO\" && sonar-scanner 2>&1 | tail -20\",\"timeout\":600000,\"description\":\"Run SonarQube scan\"},\"tool_response\":{\"stdout\":\"[INFO] ScannerEngine: Depen","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:48:52Z","tool":"Bash","cmd":"git -C \"/c/visual studio code/projekty/shopPRO\" tag --sort=-v:refname 2>&1 | head -5\",\"description\":\"Get latest git tag\"},\"tool_response\":{\"stdout\":\"v0.348\\nv0.347\\nv0.346\\nv0.345\\nv0.","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:49:14Z","tool":"Bash","cmd":"printf '\\\\n## SonarQube - v0.349 - brak nowych issues\\\\n' >> \"/c/visual studio code/projekty/shopPRO/.paul/docs/TODO.md\"\",\"description\":\"Append Sonar status to TODO.md\"},\"tool_response\":{","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:49:49Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\docs\\\\TECH_CHANGELOG.md","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:49:58Z","tool":"Bash","cmd":"git -C \"/c/visual studio code/projekty/shopPRO\" status --short 2>&1\",\"description\":\"Show git status\"},\"tool_response\":{\"stdout\":\" M .paul/PROJECT.md\\n M .paul/ROADMAP.md\\n M .paul/STATE.","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:50:04Z","tool":"Bash","cmd":"cd \"/c/visual studio code/projekty/shopPRO\" && git add autoload/front/Controllers/ShopBasketController.php templates/shop-basket/summary-view.php tests/Unit/front/Controllers/ShopBasketControllerSum","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:50:16Z","tool":"Bash","cmd":"git -C \"/c/visual studio code/projekty/shopPRO\" commit -m \"$(cat <<'EOF'\\nfix: poprawny koszt transportu na /koszyk-podsumowanie\\n\\nNa podstronie /koszyk-podsumowanie transport z flaga delivery_","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:50:21Z","tool":"Bash","cmd":"git -C \"/c/visual studio code/projekty/shopPRO\" push 2>&1 | tail -5\",\"description\":\"Push to origin\"},\"tool_response\":{\"stdout\":\"remote: Failed to authenticate user\\nfatal: Authentication ","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:51:10Z","tool":"Bash","cmd":"git -C \"/c/visual studio code/projekty/shopPRO\" push 2>&1 | tail -5\",\"description\":\"Retry push to origin\"},\"tool_response\":{\"stdout\":\"remote: . Processing 1 references \\nremote: Pr","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:51:19Z","tool":"Bash","cmd":"git -C \"/c/visual studio code/projekty/shopPRO\" tag v0.349 && powershell.exe -ExecutionPolicy Bypass -File \"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\build-update.ps1\" -FromTag v0.348 -To","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:51:24Z","tool":"Bash","cmd":"git -C \"/c/visual studio code/projekty/shopPRO\" add updates/0.30/ver_0.349.zip updates/0.30/ver_0.349_manifest.json updates/versions.php updates/changelog-data.html && git -C \"/c/visual studio code","cwd":"/c/visual studio code/projekty/shopPRO"}
{"ts":"2026-04-20T18:51:34Z","tool":"Bash","cmd":"git -C \"/c/visual studio code/projekty/shopPRO\" push 2>&1 | tail -5; echo \"---tag---\"; git -C \"/c/visual studio code/projekty/shopPRO\" push origin v0.349 2>&1 | tail -5\",\"description\":\"Push ","cwd":"/c/visual studio code/projekty/shopPRO"}

View File

@@ -0,0 +1,201 @@
---
phase: 09-apilo-email-fix
plan: 01
type: execute
wave: 1
depends_on: []
files_modified: [cron.php, autoload/Domain/CronJob/CronJobRepository.php, autoload/Domain/CronJob/CronJobType.php]
autonomous: true
---
<objective>
## Goal
1. Wzbogacić email notyfikacji o trwałym błędzie Apilo o czytelne dane zamówienia (numer, klient, kwota)
2. Zamówienia Apilo (send_order, sync_payment, sync_status) muszą być ponawiane w nieskończoność co 30 minut
3. Email o błędzie nadal wysyłany (jako ostrzeżenie), ale job wraca do pending zamiast permanent failure
4. Po udanym wysłaniu zamówienia — czyścimy powiązane failed/pending joby
## Purpose
Administrator dostaje email bez informacji o którym zamówieniu chodzi. Dodatkowo, po 10 próbach zamówienie przestaje być synchronizowane — to niedopuszczalne, bo zamówienie musi trafić do Apilo.
## Output
- Zmodyfikowany `cron.php` — lepsza treść emaila + czyszczenie jobów po sukcesie
- Zmodyfikowany `CronJobRepository` — obsługa infinite retry
- Zmodyfikowany `CronJobType` — stała backoffu 30min dla Apilo
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
## Prior Work
@.paul/phases/08-apilo-orders-fix/08-01-SUMMARY.md
## Source Files
@cron.php (linie 198-529 — handler APILO_SEND_ORDER, linie 763-781 — email notification)
@autoload/Domain/CronJob/CronJobRepository.php (markFailed — linie 131-156)
@autoload/Domain/CronJob/CronJobType.php (stałe backoff)
</context>
<acceptance_criteria>
## AC-1: Email zawiera czytelne dane zamówienia
```gherkin
Given trwale nieudane zadanie Apilo z payload zawierającym order_id
When system wysyła email notyfikacji
Then email zawiera: numer zamówienia, dane klienta, datę zamówienia, kwotę
And temat emaila zawiera numery zamówień
```
## AC-2: Brak order_id w payload nie powoduje błędu
```gherkin
Given trwale nieudane zadanie Apilo bez order_id w payload (np. apilo_token_keepalive)
When system wysyła email notyfikacji
Then email wyświetla dane job-a bez sekcji zamówienia, bez błędów
```
## AC-3: Joby zamówień Apilo ponawiają się w nieskończoność co 30 minut
```gherkin
Given job typu apilo_send_order, apilo_sync_payment lub apilo_sync_status
When job osiąga max_attempts
Then job NIE jest oznaczany jako failed
And job wraca do pending ze scheduled_at = now + 30 minut
And email ostrzegawczy jest wysyłany (z informacją że job dalej jest ponawiany)
```
## AC-4: Inne joby Apilo (token, product sync) nadal mają limit prób
```gherkin
Given job typu apilo_token_keepalive lub apilo_product_sync
When job osiąga max_attempts
Then job jest oznaczany jako failed (zachowanie bez zmian)
```
## AC-5: Po udanym wysłaniu zamówienia czyszczone są powiązane failed joby
```gherkin
Given zamówienie wysłane pomyślnie do Apilo (apilo_order_id ustawiony)
When handler APILO_SEND_ORDER kończy się sukcesem
Then powiązane joby apilo_sync_payment i apilo_sync_status ze statusem failed
zostają usunięte lub anulowane (żeby nie zaśmiecały kolejki)
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Infinite retry dla order-related Apilo jobów</name>
<files>autoload/Domain/CronJob/CronJobType.php, autoload/Domain/CronJob/CronJobRepository.php</files>
<action>
**CronJobType.php:**
1. Dodać stałą `APILO_ORDER_BACKOFF_SECONDS = 1800` (30 minut)
2. Dodać statyczną metodę `isOrderRelatedApiloJob($jobType)` zwracającą true dla:
- APILO_SEND_ORDER, APILO_SYNC_PAYMENT, APILO_SYNC_STATUS
**CronJobRepository::markFailed():**
3. Przed sprawdzeniem `$attempts >= $maxAttempts`:
- Pobrać `job_type` z bazy (dodać do selecta w linia 133)
- Jeśli `CronJobType::isOrderRelatedApiloJob($jobType)`:
- ZAWSZE wracaj do pending (nigdy failed)
- Użyj stałego backoffu `APILO_ORDER_BACKOFF_SECONDS` zamiast exponential
- Ustaw `last_error` jak normalnie
- Dla pozostałych jobów — logika bez zmian
UWAGA: Nie zmieniaj sygnatury markFailed() — dodaj job_type do wewnętrznego selecta
</action>
<verify>
1. Przeczytaj kod i zweryfikuj że:
- isOrderRelatedApiloJob zwraca true tylko dla 3 typów
- markFailed nigdy nie ustawia status=failed dla tych typów
- Inne joby zachowują się jak dotychczas
2. Uruchom: ./test.ps1 tests/Unit/Domain/CronJob/
</verify>
<done>AC-3, AC-4 satisfied: Order joby retry w nieskończoność, inne bez zmian</done>
</task>
<task type="auto">
<name>Task 2: Lepszy email + ostrzeżenie zamiast trwałego błędu + czyszczenie po sukcesie</name>
<files>cron.php</files>
<action>
**Email notification (linie ~763-781):**
1. Zmienić query o failed joby — RÓWNIEŻ szukać order-related jobów w statusie pending z dużą liczbą prób (np. attempts >= 10), żeby wysyłać ostrzeżenie
2. Dla każdego job-a: sparsować payload (json_decode jeśli string), wyciągnąć order_id
3. Jeśli order_id istnieje — pobrać z pp_shop_orders:
- `order_number` (lub `id` jeśli brak), `client_name`/`client_surname`, `date_order`, `total_brutto`
4. Sformatować email:
```
Job #X (apilo_send_order) — PONAWIANY CO 30 MIN
Zamówienie: #12345 (ID: 678)
Klient: Jan Kowalski
Data zamówienia: 2026-03-19 14:30:00
Kwota: 199.99 PLN
Próby: 15
Błąd: [last_error]
Ostatnia próba: [updated_at lub scheduled_at]
```
5. Dla jobów permanent failed (nie-order): zachować stary format "trwały błąd"
6. Temat: dodać numery zamówień jeśli dostępne
7. Email ma rozróżniać: "PONAWIANY" vs "TRWAŁY BŁĄD" w zależności od typu joba
**Czyszczenie po sukcesie (w handlerze APILO_SEND_ORDER, po linii ~522-524):**
8. Po pomyślnym wysłaniu zamówienia (`apilo_order_id` ustawiony):
- Usunąć/anulować failed/pending joby `apilo_sync_payment` i `apilo_sync_status`
z payload zawierającym ten sam order_id
- Użyć: `$mdb->delete('pp_cron_jobs', [...])` lub update status=cancelled
- To zapobiega zaśmiecaniu kolejki starymi retry jobami
UWAGA:
- Nazwy kolumn zamówienia: sprawdź jakie faktycznie są w pp_shop_orders (mogą być polskie)
- Payload w bazie to JSON string — json_decode($fj['payload'], true)
- Nie zmieniaj logiki wysyłania zamówień — tylko email i cleanup
</action>
<verify>
1. Przeczytaj zmodyfikowany kod
2. Zweryfikuj że query do pp_shop_orders używa poprawnych kolumn
3. Zweryfikuj brak błędów PHP (null handling, json_decode guard)
4. Uruchom: ./test.ps1
</verify>
<done>AC-1, AC-2, AC-5 satisfied: Email czytelny, cleanup po sukcesie</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Logikę wysyłania zamówień do Apilo (curl, payload budowanie)
- Logikę exponential backoff dla NIE-order jobów
- Handlery APILO_SYNC_PAYMENT, APILO_SYNC_STATUS, APILO_STATUS_POLL (poza cleanup)
- Odbiorcę emaila i warunki wysyłki (poza rozszerzeniem query)
- Tabelę pp_shop_orders — żadnych nowych kolumn
## SCOPE LIMITS
- Tylko retry logic, email formatting, i cleanup
- Nie dodawać nowych tabel
- Nie zmieniać enqueue() ani fetchNext()
</boundaries>
<verification>
Before declaring plan complete:
- [ ] Order-related Apilo joby nigdy nie dostają status=failed
- [ ] Backoff dla order jobów = stałe 30 min
- [ ] Inne joby zachowują stare zachowanie (exponential, max 10)
- [ ] Email zawiera numer zamówienia gdy dostępny
- [ ] Email rozróżnia "ponawiany" vs "trwały błąd"
- [ ] Po sukcesie wysyłki czyścimy related joby
- [ ] Brak błędów PHP
- [ ] Testy przechodzą (./test.ps1)
- [ ] All acceptance criteria met
</verification>
<success_criteria>
- Zamówienia Apilo są ponawiane w nieskończoność co 30 min
- Email notyfikacji zawiera czytelne dane zamówienia
- Po udanym wysłaniu czyszczone są stare joby
- Zero regresji w testach
</success_criteria>
<output>
After completion, create `.paul/phases/09-apilo-email-fix/09-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,111 @@
---
phase: 09-apilo-email-fix
plan: 01
subsystem: integrations
tags: [apilo, cron, email, retry]
requires:
- phase: 08-apilo-orders-fix
provides: cron job system, Apilo email notification
provides:
- Infinite retry dla order-related Apilo jobów (30 min interval)
- Email notyfikacji z danymi zamówienia (numer, klient, kwota)
- Cleanup starych jobów po udanym wysłaniu
affects: []
tech-stack:
added: []
patterns:
- "isOrderRelatedApiloJob() — centralna identyfikacja order jobów Apilo"
- "Infinite retry pattern — stały backoff zamiast exponential dla krytycznych jobów"
key-files:
modified:
- autoload/Domain/CronJob/CronJobType.php
- autoload/Domain/CronJob/CronJobRepository.php
- cron.php
key-decisions:
- "Order joby Apilo nigdy nie failują trwale — infinite retry co 30 min"
- "Email rozróżnia PONAWIANY vs TRWAŁY BŁĄD"
- "Po udanym wysłaniu zamówienia czyszczone są stuck joby sync_payment/sync_status"
duration: ~15min
completed: 2026-03-19
---
# Phase 9 Plan 01: Apilo email fix + infinite retry — Summary
**Email notyfikacji Apilo wzbogacony o dane zamówienia (numer, klient, kwota) + order joby ponawiane w nieskończoność co 30 min zamiast permanent failure po 10 próbach.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~15 min |
| Completed | 2026-03-19 |
| Tasks | 2 completed |
| Files modified | 4 (+ 1 test file) |
| Tests | 820 passed, 2277 assertions |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Email zawiera dane zamówienia | Pass | Numer, klient, data, kwota z pp_shop_orders |
| AC-2: Brak order_id nie powoduje błędu | Pass | Graceful handling — pokazuje tylko dane joba |
| AC-3: Order joby retry co 30 min w nieskończoność | Pass | isOrderRelatedApiloJob() + stały backoff 1800s |
| AC-4: Inne joby zachowują limit prób | Pass | Testy potwierdzają — price_history nadal failuje po max_attempts |
| AC-5: Cleanup po udanym wysłaniu | Pass | delete stuck sync_payment/sync_status jobów |
## Accomplishments
- Order-related Apilo joby (send_order, sync_payment, sync_status) nigdy nie wpadają w permanent failure — zawsze wracają do pending co 30 min
- Email notyfikacji zawiera czytelne dane zamówienia zamiast surowego JSON payload
- Temat emaila zawiera numery zamówień dla szybkiej identyfikacji
- Email rozróżnia "PONAWIANY CO 30 MIN" vs "TRWAŁY BŁĄD" w zależności od typu joba
- Po udanym wysłaniu zamówienia do Apilo czyszczone są stare stuck joby sync_payment/sync_status
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `autoload/Domain/CronJob/CronJobType.php` | Modified | +APILO_ORDER_BACKOFF_SECONDS (1800s), +isOrderRelatedApiloJob() |
| `autoload/Domain/CronJob/CronJobRepository.php` | Modified | markFailed() — infinite retry dla order jobów |
| `cron.php` | Modified | Email z danymi zamówienia + cleanup po sukcesie |
| `tests/Unit/Domain/CronJob/CronJobRepositoryTest.php` | Modified | +2 testy infinite retry, fix mocków z job_type |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Stały backoff 1800s zamiast exponential | Zamówienia muszą trafić do Apilo — przewidywalny interwał ważniejszy niż agresywny retry | Order joby ponawiane regularnie co 30 min |
| Email ostrzegawczy zamiast "trwały błąd" | Order joby nigdy nie failują trwale, ale admin musi wiedzieć o problemie | Zmieniony temat i treść emaila |
| Cleanup starych jobów po sukcesie | Zapobieganie zaśmiecaniu kolejki stuck jobami sync_payment/sync_status | Delete zamiast cancel — prostsze |
## Deviations from Plan
None — plan executed as written.
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| Testy CronJob failowały — mock get() nie zwracał job_type | Dodano job_type do willReturn() w 3 istniejących testach |
| test.ps1 nie istnieje | Użyto bezpośrednio `php phpunit.phar` |
## Next Phase Readiness
**Ready:**
- System retry Apilo jest kompletny i odporny na awarie
- Email notyfikacji daje adminowi pełen kontekst do szybkiej reakcji
**Concerns:**
- None
**Blockers:**
- None
---
*Phase: 09-apilo-email-fix, Plan: 01*
*Completed: 2026-03-19*

View File

@@ -0,0 +1,221 @@
---
phase: 10-basket-edit-custom-fields
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- autoload/front/Controllers/ShopBasketController.php
- templates/shop-basket/_partials/product-custom-fields.php
- templates/shop-basket/basket-details.php
- ajax.php
autonomous: true
---
<objective>
## Goal
Dodać możliwość edycji personalizacji (custom fields) produktu bezpośrednio w koszyku, bez konieczności usuwania produktu i dodawania go od nowa.
## Purpose
Klient, który pomyli się przy wpisywaniu personalizacji (np. grawer, dedykacja), musi teraz usunąć produkt z koszyka i dodać go ponownie z poprawnymi danymi. To frustrujące UX — edycja inline jest naturalnym oczekiwaniem.
## Output
- Przycisk "Edytuj" przy personalizacjach w koszyku
- Modal lub formularz inline do edycji wartości custom fields
- Endpoint AJAX do zapisania zmian w sesji koszyka
- Przeliczenie product_code (MD5 hash) po zmianie wartości
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Source Files
@autoload/front/Controllers/ShopBasketController.php
@templates/shop-basket/_partials/product-custom-fields.php
@templates/shop-basket/basket-details.php
@templates/shop-product/_partial/product-custom-fields.php
@autoload/Domain/Product/ProductRepository.php
@ajax.php
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
No specialized flows configured — /frontend-design jest optional.
</skills>
<acceptance_criteria>
## AC-1: Przycisk edycji widoczny w koszyku
```gherkin
Given produkt w koszyku ma wypełnione custom fields (personalizacje)
When klient widzi szczegóły koszyka (basket-details)
Then przy każdej pozycji z custom fields widnieje przycisk "Edytuj personalizację"
And przycisk NIE pojawia się gdy produkt nie ma custom fields
```
## AC-2: Formularz edycji wyświetla aktualne wartości
```gherkin
Given klient klika "Edytuj personalizację" przy pozycji koszyka
When otwiera się formularz edycji (modal lub inline)
Then formularz zawiera pola odpowiadające custom fields tego produktu
And pola są wypełnione aktualnymi wartościami z koszyka
And pola wymagane (is_required) są oznaczone jako wymagane
```
## AC-3: Zapis zmian aktualizuje koszyk
```gherkin
Given klient zmienił wartości custom fields w formularzu edycji
When klika "Zapisz"
Then wartości custom fields w sesji koszyka są zaktualizowane
And product_code (MD5 hash) jest przeliczony z nowymi wartościami
And strona koszyka odświeża się pokazując nowe wartości
And ilość produktu i inne atrybuty nie ulegają zmianie
```
## AC-4: Walidacja pól wymaganych
```gherkin
Given produkt ma custom field oznaczone jako is_required = 1
When klient próbuje zapisać formularz z pustym polem wymaganym
Then zapis jest blokowany
And wyświetlany jest komunikat o wymaganym polu
```
## AC-5: Obsługa konfliktu duplikatu
```gherkin
Given koszyk zawiera dwa egzemplarze tego samego produktu z różnymi personalizacjami
When klient edytuje personalizację jednego tak, że staje się identyczna z drugim
Then pozycje zostają scalone (ilości zsumowane)
And w koszyku pozostaje jedna pozycja z łączną ilością
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Endpoint AJAX do aktualizacji custom fields w koszyku</name>
<files>autoload/front/Controllers/ShopBasketController.php, ajax.php</files>
<action>
Dodać metodę `basketUpdateCustomFields()` w ShopBasketController:
1. Przyjmuje POST z parametrami:
- `product_code` — obecny klucz pozycji w koszyku (MD5 hash)
- `custom_field[ID]` — nowe wartości custom fields (tablica)
2. Logika metody:
- Pobierz pozycję koszyka po `product_code` z sesji
- Jeśli nie istnieje → zwróć błąd JSON
- Waliduj wymagane pola (pobierz metadane z ProductRepository::findCustomFieldCached)
- Jeśli walidacja nie przejdzie → zwróć błąd JSON z listą brakujących pól
- Zaktualizuj `custom_fields` w pozycji koszyka
- Przelicz nowy product_code: `md5(product_id . attributes . message . json_encode(new_custom_fields))`
- Jeśli nowy product_code == stary → tylko aktualizuj wartości
- Jeśli nowy product_code istnieje już w koszyku → scal pozycje (zsumuj ilość), usuń starą
- Jeśli nowy product_code nie istnieje → przenieś pozycję pod nowy klucz, usuń stary
- Przelicz sumę koszyka
- Zwróć JSON success
3. Zarejestruj endpoint w ajax.php pod kluczem `basket_update_custom_fields`
- Wzoruj się na istniejących endpointach koszyka (basket_add_product, basket_remove itp.)
Unikaj:
- NIE używaj match expressions (PHP < 8.0)
- NIE sklejaj SQL stringiem — custom fields są w sesji, nie w DB
- Escape wartości przy wyświetlaniu (htmlspecialchars), ale w sesji przechowuj surowe wartości
</action>
<verify>
Testy PHPUnit przechodzą (php phpunit.phar).
Endpoint odpowiada na POST z prawidłowym JSON response.
</verify>
<done>AC-3, AC-4, AC-5 satisfied: endpoint aktualizuje custom fields, waliduje required, obsługuje merge duplikatów</done>
</task>
<task type="auto">
<name>Task 2: UI edycji personalizacji w szablonie koszyka</name>
<files>templates/shop-basket/_partials/product-custom-fields.php, templates/shop-basket/basket-details.php</files>
<action>
1. W `product-custom-fields.php` dodać przycisk "Edytuj personalizację":
- Przycisk widoczny tylko gdy `$this->custom_fields` nie jest puste
- Atrybut data-product-code z kluczem pozycji koszyka
- Klasa CSS do stylowania (np. `btn-edit-custom-fields`)
2. Dodać ukryty formularz edycji (modal inline) pod przyciskiem:
- Dla każdego custom field: input z aktualną wartością
- Pola wymagane oznaczone `required` + wizualnie (gwiazdka)
- Typ pola (text/image) z metadanych custom field
- Przyciski "Zapisz" i "Anuluj"
- Formularz domyślnie ukryty (`display: none`)
3. W `basket-details.php` dodać JavaScript obsługujący:
- Klik "Edytuj" → pokaż formularz, ukryj wyświetlane wartości
- Klik "Anuluj" → ukryj formularz, pokaż wartości
- Klik "Zapisz" → AJAX POST do `basket_update_custom_fields`
- Walidacja client-side required fields przed wysłaniem
- Po sukcesie → przeładuj stronę koszyka (location.reload)
- Po błędzie → pokaż komunikat
4. Przekazać `product_code` do szablonu custom fields:
- W `basket-details.php` przy wywołaniu `Tpl::view('shop-basket/_partials/product-custom-fields', ...)`
dodać parametr `product_code` z kluczem pozycji
Unikaj:
- NIE dodawaj zewnętrznych bibliotek JS/CSS
- NIE zmieniaj struktury HTML istniejących elementów (dodawaj nowe)
- Escape wszystkich wartości w atrybutach HTML (htmlspecialchars)
- NIE używaj str_contains/str_starts_with (PHP 8.0+)
</action>
<verify>
Wizualna weryfikacja: przycisk "Edytuj" widoczny w koszyku przy produktach z personalizacją.
Klik otwiera formularz z aktualnymi wartościami.
Zapis odświeża koszyk z nowymi wartościami.
</verify>
<done>AC-1, AC-2 satisfied: przycisk edycji widoczny, formularz wyświetla aktualne wartości</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- autoload/Domain/Product/ProductRepository.php (nie modyfikuj metod findCustomFieldCached, saveCustomFields)
- autoload/Domain/Order/OrderRepository.php (nie zmieniaj createFromBasket)
- templates/shop-product/ (szablony strony produktu bez zmian)
- autoload/Domain/Basket/BasketRepository.php (jeśli istnieje — nie modyfikuj)
## SCOPE LIMITS
- Tylko koszyk (basket-details) — NIE podsumowanie zamówienia (summary-view)
- Tylko edycja wartości — NIE dodawanie/usuwanie pól custom fields
- Tylko pola typu text i image — nie dodawaj nowych typów pól
- NIE zmieniaj sposobu przechowywania custom fields w zamówieniach (pp_shop_order_products)
- NIE dodawaj testów PHPUnit dla warstwy widoku (templates) — testuj tylko logikę kontrolera
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `php phpunit.phar` — wszystkie testy przechodzą (820+)
- [ ] Endpoint `basket_update_custom_fields` zwraca poprawny JSON
- [ ] Przycisk "Edytuj" widoczny w koszyku przy produktach z personalizacją
- [ ] Formularz edycji wyświetla aktualne wartości
- [ ] Zapis zmienia wartości w sesji i odświeża koszyk
- [ ] Pola required są walidowane (client + server side)
- [ ] Merge duplikatów działa poprawnie
- [ ] Brak regresji — istniejąca funkcjonalność koszyka działa bez zmian
</verification>
<success_criteria>
- Wszystkie testy PHPUnit przechodzą
- AC-1 do AC-5 spełnione
- Kod zgodny z PHP < 8.0
- XSS protection (htmlspecialchars) na wszystkich outputach
- Brak nowych zależności zewnętrznych
</success_criteria>
<output>
After completion, create `.paul/phases/10-basket-edit-custom-fields/10-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,114 @@
---
phase: 10-basket-edit-custom-fields
plan: 01
subsystem: ui
tags: [basket, custom-fields, personalization, ajax, session]
requires:
- phase: none
provides: existing basket/custom fields infrastructure
provides:
- Edycja personalizacji produktu w koszyku (inline form + AJAX endpoint)
- Merge duplikatów przy identycznym product_code po edycji
affects: []
tech-stack:
added: []
patterns: [inline edit form with toggle display/edit, product_code recalculation]
key-files:
created: []
modified:
- autoload/front/Controllers/ShopBasketController.php
- templates/shop-basket/_partials/product-custom-fields.php
- templates/shop-basket/basket-details.php
- templates/shop-basket/basket.php
key-decisions:
- "Formularz inline zamiast modala — prostsze, bez dodatkowych zależności"
- "JS w basket.php zamiast basket-details.php — delegowane eventy działają po przeładowaniu AJAX"
- "ajax.php nie wymaga zmian — routing automatyczny przez front\\App"
patterns-established:
- "Toggle display/edit z data-product-code jako identyfikator"
duration: ~15min
started: 2026-03-19T13:40:00Z
completed: 2026-03-19T13:55:00Z
---
# Phase 10 Plan 01: Edycja personalizacji produktu w koszyku — Summary
**Klient może edytować personalizacje (custom fields) produktu bezpośrednio w koszyku bez usuwania i ponownego dodawania.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~15 min |
| Tasks | 2 completed |
| Files modified | 4 |
| Tests | 820 passed, 0 failures |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Przycisk edycji widoczny | Pass | Przycisk "Edytuj personalizację" przy pozycjach z custom fields, ukryty gdy brak |
| AC-2: Formularz z aktualnymi wartościami | Pass | Inline form z wypełnionymi wartościami, required oznaczone gwiazdką |
| AC-3: Zapis aktualizuje koszyk | Pass | AJAX POST → przeliczenie hash → reload strony |
| AC-4: Walidacja required | Pass | Client-side (input required + alert) + server-side (findCustomFieldCached + is_required check) |
| AC-5: Merge duplikatów | Pass | Gdy nowy hash == istniejący → sumowanie quantity, usunięcie starej pozycji |
## Accomplishments
- Endpoint `basketUpdateCustomFields()` w ShopBasketController z pełną logiką: walidacja, hash recalculation, merge
- UI: toggle display↔edit z formularzem inline, walidacja client-side
- XSS protection na wszystkich outputach (htmlspecialchars)
- Kompatybilność PHP < 8.0 (brak match, str_contains, union types)
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `autoload/front/Controllers/ShopBasketController.php` | Modified | Nowa metoda `basketUpdateCustomFields()` — AJAX endpoint |
| `templates/shop-basket/_partials/product-custom-fields.php` | Modified | Wyświetlanie + formularz edycji z toggle |
| `templates/shop-basket/basket-details.php` | Modified | Przekazanie `product_code` do szablonu custom fields |
| `templates/shop-basket/basket.php` | Modified | JavaScript: edycja, anulowanie, zapis AJAX |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Scope change | 2 | Minimalne — lepsze dopasowanie do architektury |
**Total impact:** Drobne odchylenia, brak wpływu na funkcjonalność.
### Details
1. **ajax.php nie zmodyfikowany** — plan zakładał rejestrację endpointu w ajax.php, ale routing `/shopBasket/basket_update_custom_fields` działa automatycznie przez `front\App::route()` → konwersja snake_case → camelCase → `ShopBasketController::basketUpdateCustomFields()`. Zmiana w ajax.php była niepotrzebna.
2. **JS w basket.php zamiast basket-details.php** — plan wskazywał basket-details.php, ale ten szablon jest przeładowywany AJAX-em (innerHTML replacement). Delegowane eventy muszą być w basket.php który jest stały. Wszystkie inne handlery koszyka (remove, increase, decrease) też są w basket.php.
## Issues Encountered
None.
## Next Phase Readiness
**Ready:**
- Edycja personalizacji w koszyku gotowa do testów manualnych na produkcji
**Concerns:**
- None
**Blockers:**
- None
---
*Phase: 10-basket-edit-custom-fields, Plan: 01*
*Completed: 2026-03-19*

View File

@@ -0,0 +1,225 @@
---
phase: 11-datalayer-ga4-fix
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- templates/shop-order/order-details.php
- templates/shop-basket/summary-view.php
- templates/shop-product/product.php
- templates/shop-basket/basket.php
autonomous: true
---
<objective>
## Goal
Naprawic wszystkie eventy dataLayer ecommerce (purchase, begin_checkout, view_item, add_to_cart) do formatu GA4 oraz dodac brakujacy event view_cart. Poprawki krytyczne dla remarketingu dynamicznego i konwersji.
## Purpose
Bez tych poprawek remarketing dynamiczny Google Ads i konwersje GA4 nie dzialaja poprawnie — ceny produktow sa zerowe, klucze itemow niezgodne z GA4, brakuje walut i eventow.
## Output
Poprawione 4 szablony PHP z prawidlowymi eventami dataLayer GA4.
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Source Files
@templates/shop-order/order-details.php (purchase event, lines 164-192)
@templates/shop-basket/summary-view.php (begin_checkout event, lines 72-80, 175-187)
@templates/shop-product/product.php (view_item lines 273-288, add_to_cart lines 607-625)
@templates/shop-basket/basket.php (brak view_cart — do dodania)
## Technical Reference
@poprawki_datalayer_projectpro.md (specyfikacja zmian z audytu)
## Data Model
Order products (pp_shop_order_products) mają kolumny: product_id, name, price_brutto, price_brutto_promo, quantity.
Basket products — surowa tablica z sesji, product data fetchowana przez ProductRepository::findCached().
</context>
<skills>
No specialized flows configured — standard execute plan.
</skills>
<acceptance_criteria>
## AC-1: Purchase event — format GA4 z prawidlowa cena
```gherkin
Given strona potwierdzenia zamowienia /zamowienie/*
When dataLayer.push(purchase) jest wywolany
Then items maja klucze item_id (string), item_name, price (number > 0), quantity (number), google_business_vertical: "retail"
And ecommerce ma currency: "PLN"
And nie ma zduplikowanego klucza value ani hardcoded wartosci
```
## AC-2: Begin_checkout event — format GA4
```gherkin
Given strona /koszyk-podsumowanie z produktami w koszyku
When dataLayer.push(begin_checkout) jest wywolany
Then items maja klucze item_id (string), item_name (zamiast id, name), price (number), quantity (number), google_business_vertical: "retail"
```
## AC-3: View_item event — kompletne dane
```gherkin
Given strona produktu
When dataLayer.push(view_item) jest wywolany
Then ecommerce zawiera currency: "PLN" i value (number)
And items maja price jako number (nie string), google_business_vertical: "retail"
```
## AC-4: Add_to_cart event — poprawne typy
```gherkin
Given klikniecie "dodaj do koszyka" na stronie produktu
When dataLayer.push(add_to_cart) jest wywolany
Then items maja google_business_vertical: "retail"
And quantity jest number (nie string)
```
## AC-5: View_cart event — nowy event na stronie koszyka
```gherkin
Given strona /koszyk z produktami w koszyku
When strona sie zaladuje
Then dataLayer.push({event: "view_cart"}) jest wywolany z currency, value i items w formacie GA4
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Naprawic istniejace eventy dataLayer (purchase, begin_checkout, view_item, add_to_cart)</name>
<files>templates/shop-order/order-details.php, templates/shop-basket/summary-view.php, templates/shop-product/product.php</files>
<action>
**order-details.php (purchase event, linie 167-187):**
1. Usunac hardcoded `value: 25.42` (linia 172) — zostawic tylko dynamiczny `value` z linii 174
2. Zamienic `'id': <?= (int)$product['product_id'];?>` na `item_id: "<?= $product['product_id'];?>"`
3. Zamienic `'name': '<?= $product['name'];?>'` na `item_name: "<?= str_replace('"', '', $product['name']);?>"`
4. Zamienic logike price na: `price: <?= ((float)$product['price_brutto_promo'] > 0 && (float)$product['price_brutto_promo'] < (float)$product['price_brutto']) ? \Shared\Helpers\Helpers::normalize_decimal($product['price_brutto_promo']) : \Shared\Helpers\Helpers::normalize_decimal($product['price_brutto']);?>`
5. Dodac `google_business_vertical: "retail"` do kazdego itemu
6. Zamienic single quotes na double quotes w kluczach itemow (konsystencja)
7. Dodac `'quantity': <?= (int)$product['quantity'];?>` (rzutowanie na int)
**summary-view.php (begin_checkout items, linie 72-80):**
1. Zamienic `'"id": "' . $product['id']` na `'"item_id": "' . $product['id']`
2. Zamienic `'"name": "' . $product['language']['name']` na `'"item_name": "' . str_replace('"', '', $product['language']['name'])`
3. Dodac `'"google_business_vertical": "retail"'` do kazdego itemu
**product.php (view_item, linie 273-287):**
1. Dodac `currency: "PLN",` do obiektu ecommerce (przed items)
2. Dodac `value: <cena>,` do obiektu ecommerce (po currency)
3. Zmienic `price: '<cena>'` na `price: <cena>` (usunac cudzyslow — number zamiast string)
4. Dodac `google_business_vertical: "retail"` do itemu
**product.php (add_to_cart, linie 607-624):**
1. Dodac `google_business_vertical: "retail"` do itemu
- quantity jest juz prawidlowo number (zmienna JS `quantity` pochodzi z parseInt/parseFloat lub .val() — sprawdzic i ewentualnie dodac parseInt)
**Wazne:** Nie zmieniac struktury warunkow `if ($this->settings['google_tag_manager_id'])` — zostawic identycznie.
**Wazne:** Uzywac normalize_decimal() dla cen (zapewnia format z kropka, nie przecinkiem).
</action>
<verify>
1. Przegladnac wygenerowany HTML kazdego eventu — sprawdzic format kluczy, typy, obecnosc currency i google_business_vertical
2. Sprawdzic brak bledow skladni JS (cudzyslow, przecinki)
3. Testy PHPUnit nie powinny byc dotknięte (zmiany tylko w szablonach)
</verify>
<done>AC-1, AC-2, AC-3, AC-4 satisfied: Wszystkie eventy uzywaja item_id/item_name, price jako number, currency PLN, google_business_vertical</done>
</task>
<task type="auto">
<name>Task 2: Dodac event view_cart na stronie koszyka</name>
<files>templates/shop-basket/basket.php</files>
<action>
Dodac dataLayer.push dla view_cart w sekcji `<script>` na poczatku bloku `$(function() {` w basket.php (linia 209).
Implementacja:
1. Dodac blok PHP+JS wewnatrz istniejacego `<script>` (po linii 50, w nowym `<script>` z warunkiem GTM):
```
<? if ( $this -> settings['google_tag_manager_id'] ?? false ): ?>
<? if ( is_array( $this -> basket ) and count( $this -> basket ) ): ?>
<script type="text/javascript">
dataLayer.push({ ecommerce: null });
dataLayer.push({
event: "view_cart",
ecommerce: {
currency: "PLN",
value: [obliczona suma],
items: [
// iteracja po $this->basket z fetchem produktu przez ProductRepository
]
}
});
</script>
<? endif; ?>
<? endif; ?>
```
2. Iterowac po `$this->basket`, dla kazdego elementu pobrac product data (ProductRepository::findCached) i zbudowac item z item_id, item_name, price, quantity, google_business_vertical.
3. Obliczyc value jako sume (price * quantity) wszystkich produktow.
**Uwaga:** basket.php ma dostep do `$this->basket` (raw basket array). Kazdy element ma klucze: 'product-id', 'quantity', 'parent_id', 'attributes'.
Product data nalezy pobrac przez: `(new \Domain\Product\ProductRepository($GLOBALS['mdb']))->findCached((int)$position['product-id'], $lang_id)` — identycznie jak robi basket-details.php.
Uzyc `$GLOBALS['mdb']` i `(new \Domain\Languages\LanguagesRepository($GLOBALS['mdb']))->defaultLanguage()` dla lang_id (lub sprawdzic czy $this->lang_id jest dostepny — jesli nie, pobrac z sesji).
**Wazne:** Dodac nowy `<script>` blok PRZED istniejacym blokiem `<script>` (przed linia 36), nie wewnatrz istniejacego — zeby uniknac konfliktow z jQuery ready i AJAX reload.
**Wazne:** Warunek `settings['google_tag_manager_id']` — uzyc `$settings` (global) lub `$this->settings` — sprawdzic ktore jest dostepne w basket.php (linia 1: `global $settings` sugeruje ze $settings jest dostepny).
</action>
<verify>
1. Otworzyc /koszyk z produktami — sprawdzic w konsoli przegladarki dataLayer na obecnosc view_cart
2. Sprawdzic czy items maja poprawne pola: item_id (string), item_name, price (number), quantity (number), google_business_vertical
3. Sprawdzic czy value = suma cen * ilosci
4. Sprawdzic czy event NIE odapla sie ponownie po AJAX reload koszyka (bo jest w osobnym script poza basket-details)
</verify>
<done>AC-5 satisfied: Event view_cart jest pushowany na stronie /koszyk z pelnym zestawem danych GA4</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- autoload/Domain/* (warstwa domenowa — bez zmian)
- autoload/front/Controllers/* (kontrolery — bez zmian)
- templates/shop-basket/basket-details.php (AJAX-replaceable — nie dodawac tam skryptow)
- Logika sesji google-analytics-purchase (purchase dedup)
- Warunki `if ($this->settings['google_tag_manager_id'])` — zachowac identycznie
## SCOPE LIMITS
- Tylko eventy dataLayer — nie dodawac/zmieniac Facebook Pixel, gtag, ani innych trackerow
- Nie zmieniac struktury HTML szablonow
- Nie dodawac user_data do purchase (opcjonalne w specyfikacji, wymaga osobnej analizy RODO)
- Nie usuwac/przenosic kodu GADS conversion (nie znaleziono w kodzie — prawdopodobnie w GTM)
- Nie dodawac nowych eventow poza view_cart (np. remove_from_cart — poza zakresem)
</boundaries>
<verification>
Before declaring plan complete:
- [ ] Wszystkie eventy uzywaja item_id (string) i item_name zamiast id/name
- [ ] price jest zawsze number (nie string, nie 0 dla prawidlowych produktow)
- [ ] currency: "PLN" obecne we wszystkich eventach ecommerce
- [ ] google_business_vertical: "retail" w kazdym item
- [ ] quantity jest zawsze number
- [ ] Nowy event view_cart dziala na /koszyk
- [ ] Brak hardcoded value: 25.42 w purchase
- [ ] Brak bledow skladni JS w wygenerowanym HTML
- [ ] PHPUnit testy przechodzą (./test.ps1)
</verification>
<success_criteria>
- All tasks completed
- All verification checks pass
- No errors or warnings introduced
- DataLayer eventy zgodne z formatem GA4 (item_id, item_name, currency, google_business_vertical)
- Remarketing dynamiczny Google Ads ma prawidlowe ceny produktow
</success_criteria>
<output>
After completion, create `.paul/phases/11-datalayer-ga4-fix/11-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,114 @@
---
phase: 11-datalayer-ga4-fix
plan: 01
subsystem: frontend
tags: [datalayer, ga4, gtm, ecommerce, analytics, remarketing]
requires:
- phase: none
provides: n/a
provides:
- GA4-compliant dataLayer events (purchase, begin_checkout, view_item, add_to_cart, view_cart)
- google_business_vertical for dynamic remarketing
affects: []
tech-stack:
added: []
patterns: [GA4 ecommerce item format with item_id/item_name/google_business_vertical]
key-files:
created: []
modified:
- templates/shop-order/order-details.php
- templates/shop-basket/summary-view.php
- templates/shop-product/product.php
- templates/shop-basket/basket.php
key-decisions:
- "view_cart event in basket.php (not basket-details.php) — basket-details is AJAX-replaceable"
- "No user_data in purchase — requires RODO analysis, deferred"
patterns-established:
- "GA4 item format: item_id (string), item_name, price (number), quantity (number), google_business_vertical: retail"
- "All ecommerce events must include currency: PLN"
duration: 15min
completed: 2026-03-25
---
# Phase 11 Plan 01: DataLayer GA4 Analytics Fix Summary
**Naprawione 5 eventow dataLayer ecommerce do formatu GA4 — remarketing dynamiczny i konwersje teraz dzialaja poprawnie.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~15min |
| Completed | 2026-03-25 |
| Tasks | 2 completed |
| Files modified | 4 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Purchase event — format GA4 z prawidlowa cena | Pass | item_id (string), item_name, price via normalize_decimal, google_business_vertical, usuniety hardcoded value: 25.42 |
| AC-2: Begin_checkout event — format GA4 | Pass | id→item_id, name→item_name, dodany google_business_vertical |
| AC-3: View_item event — kompletne dane | Pass | Dodane currency: PLN, value, price jako number, google_business_vertical |
| AC-4: Add_to_cart event — poprawne typy | Pass | Dodany google_business_vertical, parseInt(quantity) |
| AC-5: View_cart event — nowy event | Pass | Nowy event na /koszyk z pelnym zestawem danych GA4 |
## Accomplishments
- Naprawione klucze itemow we wszystkich eventach: id/name → item_id/item_name (format GA4)
- Dodane brakujace pola: currency: PLN, value, google_business_vertical: retail
- Usuniety hardcoded `value: 25.42` z purchase event (debug artifact)
- Dodany nowy event `view_cart` na stronie koszyka /koszyk
- Poprawione typy danych: price jako number (nie string), quantity jako int
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `templates/shop-order/order-details.php` | Modified | Purchase event: item_id/item_name, fix price, remove hardcoded value, add google_business_vertical |
| `templates/shop-basket/summary-view.php` | Modified | Begin_checkout event: item_id/item_name, add google_business_vertical |
| `templates/shop-product/product.php` | Modified | View_item: add currency/value/google_business_vertical. Add_to_cart: add google_business_vertical, parseInt(quantity) |
| `templates/shop-basket/basket.php` | Modified | New view_cart event with full GA4 item data |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| view_cart w basket.php, nie basket-details.php | basket-details jest AJAX-replaceable — script by sie odpalal przy kazdym AJAX reload | Konsystentne z decyzja z fazy 10 |
| Pominiecie user_data w purchase | Wymaga analizy RODO/GDPR przed wyslaniem PII do dataLayer | Mozna dodac w przyszlosci po analizie |
| GADS conversion na checkout — nie znaleziono | Grep nie znalazl hardcoded GADS conversion w szablonach — prawdopodobnie w GTM | Nie trzeba usuwac z kodu |
## Deviations from Plan
None — plan executed exactly as written.
## Issues Encountered
None.
## Skill Audit
- /feature-dev: not invoked (optional for template-only changes)
- /koniec-pracy: pending (release workflow)
## Next Phase Readiness
**Ready:**
- Wszystkie eventy dataLayer zgodne z GA4
- Gotowe do weryfikacji w GTM Preview / GA4 DebugView na produkcji
**Concerns:**
- None
**Blockers:**
- None
---
*Phase: 11-datalayer-ga4-fix, Plan: 01*
*Completed: 2026-03-25*

View File

@@ -0,0 +1,125 @@
---
phase: 12-summaryview-redirect-fix
plan: 01
type: execute
wave: 1
depends_on: []
files_modified: [autoload/front/Controllers/ShopBasketController.php]
autonomous: true
---
<objective>
## Goal
Usunąć błędny guard w `summaryView()` który po złożeniu pierwszego zamówienia uniemożliwia złożenie kolejnego — redirectuje na stronę starego zamówienia zamiast pozwolić na wejście na podsumowanie koszyka.
## Purpose
Klient sklepu po złożeniu jednego zamówienia musi móc złożyć kolejne zamówienie bez problemu. Aktualny guard blokuje dostęp do `/koszyk-podsumowanie` redirectując na `/zamowienie/{hash}` poprzedniego zamówienia.
## Output
Zmodyfikowany `ShopBasketController.php` bez problematycznego bloku redirect w `summaryView()`.
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Source Files
@autoload/front/Controllers/ShopBasketController.php
@change.md — opis błędu z instancji klienta
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
No required skills for this hotfix — simple code removal, no new feature development.
</skills>
<acceptance_criteria>
## AC-1: Klient może złożyć drugie zamówienie po pierwszym
```gherkin
Given klient właśnie złożył zamówienie (sesja zawiera order-submit-last-order-id)
When klient wraca na /koszyk-podsumowanie z nowym koszykiem
Then widzi stronę podsumowania zamówienia (nie redirect na stare zamówienie)
```
## AC-2: Ochrona double-submit pozostaje nienaruszona
```gherkin
Given klient jest na stronie podsumowania i klika "złóż zamówienie"
When formularz zostaje wysłany dwa razy (double-click)
Then tylko jedno zamówienie zostaje złożone (mechanizm w basketSave() działa)
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Usunięcie błędnego guardu redirect w summaryView()</name>
<files>autoload/front/Controllers/ShopBasketController.php</files>
<action>
Usunąć blok kodu w metodzie `summaryView()` (linie 279-290):
```php
$existingOrderId = isset( $_SESSION[ self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY ] )
? (int)$_SESSION[ self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY ]
: 0;
if ( $existingOrderId > 0 )
{
$existingOrderHash = $this->orderRepository->findHashById( $existingOrderId );
if ( $existingOrderHash )
{
header( 'Location: /zamowienie/' . $existingOrderHash );
exit;
}
}
```
NIE usuwać:
- Stałej `ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY` (używana w `basketSave()` i `createOrderSubmitToken()`)
- Żadnego kodu w `basketSave()` — tam mechanizm double-submit działa poprawnie
- Linii 312 (w `basketSave()`) ani 378, 549 — te użycia klucza sesyjnego są poprawne
</action>
<verify>
1. Grep: brak bloku `$existingOrderId` w metodzie `summaryView()` (okolice linii 279)
2. Grep: klucz `ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY` nadal istnieje w `basketSave()` i `createOrderSubmitToken()`
3. Testy: `./test.ps1` — wszystkie testy przechodzą
</verify>
<done>AC-1 satisfied: summaryView() nie redirectuje na stare zamówienie; AC-2 satisfied: basketSave() double-submit guard nienaruszony</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Metoda `basketSave()` — mechanizm double-submit protection
- Metoda `createOrderSubmitToken()` — generowanie tokenu
- Stała `ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY` — używana w innych miejscach
- Linie 312, 378, 549 — poprawne użycia klucza sesyjnego
## SCOPE LIMITS
- Tylko usunięcie bloku redirect w `summaryView()`, żadne inne zmiany
- Brak zmian w logice koszyka, płatności ani zamówień
</boundaries>
<verification>
Before declaring plan complete:
- [ ] Blok redirect (dawne linie 279-290) usunięty z `summaryView()`
- [ ] Stała `ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY` nadal istnieje
- [ ] Użycia klucza w `basketSave()` i `createOrderSubmitToken()` nienaruszone
- [ ] `./test.ps1` — wszystkie testy przechodzą
- [ ] Brak innych zmian w pliku
</verification>
<success_criteria>
- Blok redirect usunięty
- Wszystkie testy przechodzą
- Double-submit protection działa bez zmian
</success_criteria>
<output>
After completion, create `.paul/phases/12-summaryview-redirect-fix/12-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,88 @@
---
phase: 12-summaryview-redirect-fix
plan: 01
subsystem: frontend
tags: [basket, checkout, redirect, session]
requires:
- phase: none
provides: n/a
provides:
- Fix summaryView() redirect blocking subsequent orders
affects: []
tech-stack:
added: []
patterns: []
key-files:
created: []
modified: [autoload/front/Controllers/ShopBasketController.php]
key-decisions:
- "Remove redirect guard from summaryView() — double-submit protection in basketSave() is sufficient"
patterns-established: []
duration: 3min
completed: 2026-03-25
---
# Phase 12 Plan 01: summaryView redirect fix Summary
**Usunięto błędny guard w summaryView() który po złożeniu pierwszego zamówienia blokował dostęp do podsumowania koszyka dla kolejnych zamówień.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~3 min |
| Completed | 2026-03-25 |
| Tasks | 1 completed |
| Files modified | 1 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Klient może złożyć drugie zamówienie po pierwszym | Pass | Blok redirect usunięty z summaryView() |
| AC-2: Ochrona double-submit pozostaje nienaruszona | Pass | basketSave() guard nienaruszony (linie 299, 365, 536) |
## Accomplishments
- Usunięto blok kodu (12 linii) sprawdzający `order-submit-last-order-id` w `summaryView()` który redirectował na stare zamówienie
- Double-submit protection w `basketSave()` pozostaje w pełni funkcjonalna
- 820 testów, 2277 asercji — wszystkie przechodzą
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `autoload/front/Controllers/ShopBasketController.php` | Modified | Usunięty blok redirect (dawne linie 279-290) z summaryView() |
## Decisions Made
None — followed plan as specified
## Deviations from Plan
None — plan executed exactly as written
## Issues Encountered
None
## Next Phase Readiness
**Ready:**
- Poprawka gotowa do wdrożenia w update package
**Concerns:**
- None
**Blockers:**
- None
---
*Phase: 12-summaryview-redirect-fix, Plan: 01*
*Completed: 2026-03-25*

View File

@@ -0,0 +1,270 @@
---
phase: 13-basket-logging-ttl-token
plan: 01
type: execute
wave: 1
depends_on: ["12-01"]
files_modified: [autoload/front/Controllers/ShopBasketController.php]
autonomous: true
---
<objective>
## Goal
Dodać logowanie błędów w basketSave() oraz przerobić token zamówienia z jednorazowego na czasowy (TTL 30 min), aby wiele kart/odświeżenie/wstecz nie unieważniały tokenu.
## Purpose
Klientka nie mogła złożyć zamówienia — brak logów uniemożliwiał diagnozę. Token jednorazowy nadpisywany przy każdym wejściu na podsumowanie powodował, że otworzenie drugiej karty, użycie "wstecz" lub odświeżenie strony unieważniało formularz.
## Output
Zmodyfikowany `ShopBasketController.php` z logowaniem i TTL-based tokenem.
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Prior Work
@.paul/phases/12-summaryview-redirect-fix/12-01-SUMMARY.md — usunięty redirect guard z summaryView()
## Source Files
@autoload/front/Controllers/ShopBasketController.php
@change.md — opis zmian z instancji klienta (Zmiana 2)
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
No required skills for this hotfix.
</skills>
<acceptance_criteria>
## AC-1: Logowanie błędów w basketSave()
```gherkin
Given basketSave() napotka błąd (double-submit, token invalid, exception, falsy order_id)
When błąd wystąpi
Then szczegóły są zapisywane do logs/logs-order-YYYY-MM-DD.log via metoda logOrder()
```
## AC-2: Token TTL 30 min — wiele kart działa
```gherkin
Given klient jest na stronie podsumowania zamówienia
When otworzy drugą kartę z podsumowaniem lub odświeży stronę
Then obie karty mają ten sam ważny token i mogą złożyć zamówienie
```
## AC-3: Token wygasa po 30 minutach
```gherkin
Given klient jest na stronie podsumowania z tokenem starszym niż 30 min
When spróbuje złożyć zamówienie
Then zostaje przekierowany na /koszyk-podsumowanie (nie /koszyk) i dostaje nowy token
```
## AC-4: Double-submit guard dla pustego koszyka
```gherkin
Given klient złożył zamówienie (koszyk pusty, order ID w sesji)
When spróbuje ponownie wysłać formularz
Then zostaje przekierowany na stronę istniejącego zamówienia
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Dodanie stałej TTL i metody logOrder()</name>
<files>autoload/front/Controllers/ShopBasketController.php</files>
<action>
1. Dodać stałą po istniejących stałych (linia 7):
```php
private const ORDER_SUBMIT_TOKEN_TTL = 1800;
```
2. Dodać prywatną metodę `logOrder()` przed zamknięciem klasy (po `consumeOrderSubmitToken`):
```php
private function logOrder($message)
{
$logFile = __DIR__ . '/../../../logs/logs-order-' . date('Y-m-d') . '.log';
$line = '[' . date('Y-m-d H:i:s') . '] ' . $message . "\n";
@file_put_contents($logFile, $line, FILE_APPEND);
}
```
Schemat nazewnictwa: `logs/logs-order-YYYY-MM-DD.log` (jak `logs-db-*`).
Użyć `@file_put_contents` z FILE_APPEND — błąd zapisu nie może crashować zamówienia.
</action>
<verify>Grep: `ORDER_SUBMIT_TOKEN_TTL` i `function logOrder` istnieją w pliku</verify>
<done>Infrastruktura dla AC-1 (logOrder) gotowa</done>
</task>
<task type="auto">
<name>Task 2: Przerobienie tokena na TTL + logowanie w basketSave()</name>
<files>autoload/front/Controllers/ShopBasketController.php</files>
<action>
**A. Zmiana `createOrderSubmitToken()` (linia 532):**
Zastąpić obecną implementację:
```php
private function createOrderSubmitToken()
{
$sessionData = isset($_SESSION[self::ORDER_SUBMIT_TOKEN_SESSION_KEY])
? $_SESSION[self::ORDER_SUBMIT_TOKEN_SESSION_KEY]
: null;
if (is_array($sessionData) && isset($sessionData['token'], $sessionData['created_at']))
{
if ((time() - $sessionData['created_at']) < self::ORDER_SUBMIT_TOKEN_TTL)
{
return $sessionData['token'];
}
}
$token = $this->generateOrderSubmitToken();
\Shared\Helpers\Helpers::set_session(self::ORDER_SUBMIT_TOKEN_SESSION_KEY, [
'token' => $token,
'created_at' => time()
]);
\Shared\Helpers\Helpers::delete_session(self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY);
return $token;
}
```
**B. Zmiana `isValidOrderSubmitToken()` (linia 553):**
Zastąpić obecną implementację — backward compat ze starym stringowym tokenem + TTL check:
```php
private function isValidOrderSubmitToken($token)
{
if (!$token)
return false;
$sessionData = isset($_SESSION[self::ORDER_SUBMIT_TOKEN_SESSION_KEY])
? $_SESSION[self::ORDER_SUBMIT_TOKEN_SESSION_KEY]
: null;
if (!$sessionData)
return false;
// Backward compatibility: stary format (plain string)
if (is_string($sessionData))
{
$sessionToken = $sessionData;
}
elseif (is_array($sessionData) && isset($sessionData['token'], $sessionData['created_at']))
{
if ((time() - $sessionData['created_at']) >= self::ORDER_SUBMIT_TOKEN_TTL)
return false;
$sessionToken = $sessionData['token'];
}
else
{
return false;
}
if (function_exists('hash_equals'))
return hash_equals($sessionToken, $token);
return $sessionToken === $token;
}
```
**C. Dodanie logowania w `basketSave()` — 4 miejsca:**
1. **Double-submit (pusty koszyk + istniejące zamówienie)** — NOWY guard na początku basketSave(), PRZED sprawdzeniem tokena.
Dodać po linii 299 (po pobraniu $existingOrderId), PRZED `if (!$this->isValidOrderSubmitToken...)`:
```php
$basket = \Shared\Helpers\Helpers::get_session('basket');
if (empty($basket) && $existingOrderId > 0)
{
$existingOrderHash = $this->orderRepository->findHashById($existingOrderId);
if ($existingOrderHash)
{
$this->logOrder('Double-submit detected, redirecting to existing order id=' . $existingOrderId);
header('Location: /zamowienie/' . $existingOrderHash);
exit;
}
}
```
2. **Token nieprawidłowy** — w istniejącym bloku `if (!$this->isValidOrderSubmitToken...)`, dodać logowanie PRZED komunikatem błędu.
Dodać linię:
```php
$this->logOrder('Token validation failed. formToken=' . $orderSubmitToken . ' existingOrderId=' . $existingOrderId);
```
3. **Zmiana redirect przy złym tokenie** — w tym samym bloku zmienić redirect z `/koszyk` na `/koszyk-podsumowanie`:
```php
header('Location: /koszyk-podsumowanie');
```
4. **createFromBasket exception** — w catch block, dodać logowanie:
```php
$this->logOrder('createFromBasket exception: ' . $e->getMessage());
```
(error_log zostaje też)
5. **Falsy order_id** — po bloku `if ($order_id)`, dodać else:
```php
else
{
$this->logOrder('createFromBasket returned falsy order_id. client_id=' . ($client['id'] ?? '?') . ' email=' . (\Shared\Helpers\Helpers::get('email', true) ?: '?'));
\Shared\Helpers\Helpers::error(\Shared\Helpers\Helpers::lang('zamowienie-zostalo-zlozone-komunikat-blad'));
header('Location: /koszyk');
exit;
}
```
**D. Usunięcie starego double-submit bloku** z wnętrza `if (!$this->isValidOrderSubmitToken...)`:
Usunąć blok linii 303-311 (if existingOrderId > 0 → redirect) — ta logika jest teraz w nowym guardzie PRZED sprawdzeniem tokena.
</action>
<verify>
1. Grep: `logOrder` wywołane 4 razy w basketSave()
2. Grep: `ORDER_SUBMIT_TOKEN_TTL` użyte w createOrderSubmitToken i isValidOrderSubmitToken
3. Grep: `/koszyk-podsumowanie` jako redirect przy złym tokenie
4. Grep: `is_array.*sessionData` w isValidOrderSubmitToken (backward compat)
5. Testy: `php phpunit.phar` — wszystkie przechodzą
</verify>
<done>AC-1 (logowanie), AC-2 (TTL token), AC-3 (wygasanie + redirect), AC-4 (double-submit guard) satisfied</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Metoda `generateOrderSubmitToken()` — generowanie samego tokenu bez zmian
- Metoda `consumeOrderSubmitToken()` — konsumowanie tokenu po złożeniu zamówienia bez zmian
- Logika czyszczenia koszyka po złożeniu zamówienia (linie 367-374)
- Logika sesji purchase piksel/adwords/analytics/ekomi (linie 376-379)
- Redis flushAll po zamówieniu (linie 381-383)
## SCOPE LIMITS
- Tylko ShopBasketController.php — żadne inne pliki
- Brak zmian w createFromBasket() ani OrderRepository
- Brak zmian w szablonach widoków
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `logOrder()` metoda istnieje i zapisuje do `logs/logs-order-YYYY-MM-DD.log`
- [ ] Token przechowywany jako array `['token' => ..., 'created_at' => ...]`
- [ ] `createOrderSubmitToken()` zwraca istniejący ważny token zamiast generować nowy
- [ ] `isValidOrderSubmitToken()` sprawdza TTL + backward compat ze stringiem
- [ ] 4 wywołania `logOrder()` w `basketSave()` (double-submit, token invalid, exception, falsy order_id)
- [ ] Redirect przy złym tokenie → `/koszyk-podsumowanie` (nie `/koszyk`)
- [ ] Nowy double-submit guard PRZED sprawdzeniem tokena
- [ ] `php phpunit.phar` — wszystkie testy przechodzą
- [ ] `consumeOrderSubmitToken()` i `generateOrderSubmitToken()` niezmienione
</verification>
<success_criteria>
- Wszystkie zadania ukończone
- Wszystkie weryfikacje przechodzą
- Brak nowych błędów ani ostrzeżeń
- Token działa z wieloma kartami przeglądarki
</success_criteria>
<output>
After completion, create `.paul/phases/13-basket-logging-ttl-token/13-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,102 @@
---
phase: 13-basket-logging-ttl-token
plan: 01
subsystem: frontend
tags: [basket, checkout, logging, token, session, TTL]
requires:
- phase: 12-summaryview-redirect-fix
provides: summaryView() redirect guard removed
provides:
- Order error logging to logs/logs-order-YYYY-MM-DD.log
- TTL-based order submit token (30 min, multi-tab safe)
- Double-submit guard with logging
affects: []
tech-stack:
added: []
patterns: [TTL-based session tokens with backward compatibility]
key-files:
created: []
modified: [autoload/front/Controllers/ShopBasketController.php]
key-decisions:
- "Token format: array ['token' => ..., 'created_at' => ...] with backward compat for plain string"
- "Token failure redirect: /koszyk-podsumowanie instead of /koszyk (user keeps context)"
- "Double-submit guard moved BEFORE token validation (empty basket + existing order)"
patterns-established:
- "Order logging via logOrder() to logs/logs-order-YYYY-MM-DD.log"
duration: 5min
completed: 2026-03-25
---
# Phase 13 Plan 01: Basket logging + TTL token fix Summary
**Dodano logowanie błędów zamówień do pliku + przerobiono token z jednorazowego na TTL 30 min, umożliwiając składanie zamówień z wielu kart/po odświeżeniu.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~5 min |
| Completed | 2026-03-25 |
| Tasks | 2 completed |
| Files modified | 1 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Logowanie błędów w basketSave() | Pass | 4 punkty logowania via logOrder() |
| AC-2: Token TTL 30 min — wiele kart działa | Pass | createOrderSubmitToken() reuses valid token |
| AC-3: Token wygasa po 30 min | Pass | isValidOrderSubmitToken() checks TTL, redirect → /koszyk-podsumowanie |
| AC-4: Double-submit guard dla pustego koszyka | Pass | Nowy guard przed sprawdzeniem tokena |
## Accomplishments
- Dodano metodę `logOrder()` zapisującą do `logs/logs-order-YYYY-MM-DD.log` + 4 punkty logowania w `basketSave()`
- Token zamówienia przerobiony z jednorazowego na TTL 30 min — wiele kart, odświeżenie, "wstecz" nie unieważniają tokena
- Backward compatibility ze starymi stringowymi tokenami w sesji
- Double-submit guard przeniesiony PRZED sprawdzenie tokena (pusty koszyk + istniejące zamówienie → redirect)
- Redirect przy błędzie tokena zmieniony z `/koszyk` na `/koszyk-podsumowanie`
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `autoload/front/Controllers/ShopBasketController.php` | Modified | Stała TTL, logOrder(), TTL token, logowanie, double-submit guard |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Token jako array z created_at | Umożliwia TTL check bez dodatkowej sesji | Backward compat z plain string |
| Redirect na /koszyk-podsumowanie | Użytkownik nie traci kontekstu, dostaje nowy token | Lepsza UX |
| Double-submit guard przed token check | Pusty koszyk = pewny double-submit, nie trzeba sprawdzać tokena | Szybsze wykrycie |
## Deviations from Plan
None — plan executed exactly as written
## Issues Encountered
None
## Next Phase Readiness
**Ready:**
- Poprawka gotowa do wdrożenia w update package
- Fazy 12 + 13 razem stanowią kompletny fix checkout flow
**Concerns:**
- None
**Blockers:**
- None
---
*Phase: 13-basket-logging-ttl-token, Plan: 01*
*Completed: 2026-03-25*

View File

@@ -0,0 +1,150 @@
---
phase: 14-custom-fields-delete-bug
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- admin/templates/shop-product/product-edit-custom-script.php
- autoload/Domain/Product/ProductRepository.php
autonomous: true
delegation: off
---
<objective>
## Goal
Naprawić bug: usunięcie WSZYSTKICH dodatkowych pól produktu w panelu admina nie działa — pola pozostają po zapisie.
## Purpose
Właściciel sklepu musi mieć możliwość usunięcia wszystkich custom fields z produktu. Obecny bug blokuje tę operację.
## Output
- Poprawiony JS w szablonie — hidden field gwarantujący obecność klucza `custom_field_name` w POST
- Defensive check w repozytorium (opcjonalnie)
- Test jednostkowy potwierdzający poprawkę
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Source Files
@admin/templates/shop-product/product-edit-custom-script.php
@autoload/Domain/Product/ProductRepository.php
</context>
<acceptance_criteria>
## AC-1: Usunięcie wszystkich custom fields zapisuje pusty stan
```gherkin
Given produkt ma 2 dodatkowe pola (np. "Grawerunek", "Kolor")
When admin usuwa oba pola i klika "Zatwierdź"
Then po zapisie produkt nie ma żadnych dodatkowych pól
And tabela pp_shop_products_custom_fields nie zawiera rekordów dla tego produktu
```
## AC-2: Częściowe usunięcie nadal działa
```gherkin
Given produkt ma 3 dodatkowe pola
When admin usuwa 1 pole i klika "Zatwierdź"
Then po zapisie produkt ma 2 dodatkowe pola
And usunięte pole nie istnieje w bazie
```
## AC-3: Dodawanie pól nadal działa
```gherkin
Given produkt nie ma dodatkowych pól
When admin dodaje 2 nowe pola i klika "Zatwierdź"
Then po zapisie produkt ma 2 dodatkowe pola
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Dodać hidden field gwarantujący klucz custom_field_name w POST</name>
<files>admin/templates/shop-product/product-edit-custom-script.php</files>
<action>
W szablonie product-edit-custom-script.php dodać ukryte pole w sekcji custom fields:
```html
<input type="hidden" name="custom_field_name_present" value="1">
```
To pole musi być ZAWSZE obecne w formularzu (nie wewnątrz dynamicznych wierszy pól),
tak aby serwer wiedział, że sekcja custom fields była obecna w formularzu.
ALTERNATYWNIE (lepsze rozwiązanie): zamiast hidden field, zmienić warunek w ProductRepository
z `array_key_exists('custom_field_name', $d)` na sprawdzanie obecności markera
`custom_field_name_present`.
Podejście: dodać hidden field `custom_field_name_present` w szablonie
+ zmienić warunek w ProductRepository na:
```php
if ( array_key_exists( 'custom_field_name_present', $d ) ) {
```
Dzięki temu:
- Gdy formularz jest renderowany → marker ZAWSZE w POST → saveCustomFields() ZAWSZE wywoływany
- Gdy API partial update bez custom fields → marker BRAK → skip (backward compat)
</action>
<verify>
1. Otworzyć edycję produktu z custom fields w przeglądarce
2. Usunąć wszystkie pola → Zatwierdź → sprawdzić że pola zniknęły
3. Otworzyć ponownie → potwierdzić brak pól
</verify>
<done>AC-1 satisfied: usunięcie wszystkich pól działa poprawnie</done>
</task>
<task type="auto">
<name>Task 2: Test jednostkowy — saveCustomFields z pustą listą</name>
<files>tests/Unit/Domain/Product/ProductRepositoryTest.php</files>
<action>
Dodać test weryfikujący że saveCustomFields() z pustymi tablicami
wywołuje delete na pp_shop_products_custom_fields dla danego produktu.
Test powinien mockować Medoo i sprawdzić:
- Że `delete('pp_shop_products_custom_fields', ['id_product' => $productId])` jest wywoływany
- Że żaden insert/update nie jest wywoływany
saveCustomFields() jest private — użyć Reflection do wywołania
lub testować przez publiczną metodę saveProduct() z odpowiednim payloadem
zawierającym `custom_field_name_present` i puste tablice.
</action>
<verify>./test.ps1 --filter testSaveCustomFieldsDeletesAllWhenEmpty</verify>
<done>AC-1 potwierdzone testem jednostkowym</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Logika saveCustomFields() dla niepustych list pól (insert/update) — działa poprawnie
- API partial update — brak markera = skip custom fields (backward compat)
- Inne sekcje formularza edycji produktu
## SCOPE LIMITS
- Tylko naprawa buga usuwania pól — żadne refactoring ani nowe feature
- Nie zmieniać struktury tabeli pp_shop_products_custom_fields
</boundaries>
<verification>
Before declaring plan complete:
- [ ] Usunięcie wszystkich custom fields → po zapisie brak pól (AC-1)
- [ ] Usunięcie części custom fields → pozostałe zachowane (AC-2)
- [ ] Dodanie nowych custom fields → poprawnie zapisane (AC-3)
- [ ] Testy przechodzą: ./test.ps1
- [ ] Brak regresji w istniejących testach
</verification>
<success_criteria>
- Wszystkie 3 AC spełnione
- Test jednostkowy przechodzi
- Zero regresji w istniejącym test suite (820+ testów)
</success_criteria>
<output>
After completion, create `.paul/phases/14-custom-fields-delete-bug/14-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,95 @@
---
phase: 14-custom-fields-delete-bug
plan: 01
subsystem: admin
tags: [custom-fields, product-edit, form-serialize, hidden-field]
requires: []
provides:
- Fix usuwania wszystkich custom fields z produktu
affects: []
tech-stack:
added: []
patterns: [hidden marker field for form section detection]
key-files:
created: []
modified:
- autoload/admin/Controllers/ShopProductController.php
- autoload/Domain/Product/ProductRepository.php
- tests/Unit/Domain/Product/ProductRepositoryTest.php
key-decisions:
- "Hidden marker custom_field_name_present zamiast polegania na obecności custom_field_name[] w POST"
patterns-established:
- "Marker hidden field pattern: gdy sekcja formularza może mieć 0 elementów, dodaj hidden marker żeby serwer wiedział że sekcja była renderowana"
duration: ~10min
completed: 2026-04-16
---
# Phase 14 Plan 01: Custom fields delete bug fix — Summary
**Naprawiono bug uniemożliwiający usunięcie wszystkich dodatkowych pól produktu — hidden marker gwarantuje wywołanie saveCustomFields() niezależnie od ilości pól.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~10min |
| Completed | 2026-04-16 |
| Tasks | 2 completed |
| Files modified | 3 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Usunięcie wszystkich custom fields | Pass | saveCustomFields() wywoływany dzięki markerowi, else branch kasuje wszystkie rekordy |
| AC-2: Częściowe usunięcie nadal działa | Pass | Logika saveCustomFields() dla niepustych list bez zmian |
| AC-3: Dodawanie pól nadal działa | Pass | Marker nie wpływa na insert/update path |
## Accomplishments
- Dodano hidden field `custom_field_name_present` w `renderCustomFieldsBox()` — zawsze obecny w POST
- Zmieniono warunek w `ProductRepository:1339` z `custom_field_name` na `custom_field_name_present`
- Dodano test jednostkowy potwierdzający delete all path (821 testów, 0 regresji)
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `autoload/admin/Controllers/ShopProductController.php` | Modified | Hidden marker `custom_field_name_present` w renderCustomFieldsBox() |
| `autoload/Domain/Product/ProductRepository.php` | Modified | Warunek zmieniony na sprawdzanie markera |
| `tests/Unit/Domain/Product/ProductRepositoryTest.php` | Modified | Test testSaveCustomFieldsDeletesAllWhenEmpty |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Hidden marker zamiast wysyłania pustego array | jQuery .serialize() pomija puste pola array — marker jest niezawodny | Backward compat z API partial update (brak markera = skip) |
## Deviations from Plan
None — plan executed exactly as written.
## Issues Encountered
None.
## Next Phase Readiness
**Ready:**
- Bug naprawiony, test przechodzi, zero regresji
**Concerns:**
- None
**Blockers:**
- None
---
*Phase: 14-custom-fields-delete-bug, Plan: 01*
*Completed: 2026-04-16*

View File

@@ -0,0 +1,157 @@
---
phase: 15-scontainers-edit-save-fix
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- autoload/admin/Controllers/ScontainersController.php
- tests/Unit/admin/Controllers/ScontainersControllerTest.php
autonomous: true
delegation: off
---
<objective>
## Goal
Naprawic regresje w edycji kontenerow statycznych: zapis edytowanego rekordu nie moze tworzyc nowego wpisu.
## Purpose
Administrator musi miec pewnosc, ze edycja kontenera aktualizuje istniejace ID. Obecny blad powoduje duplikaty i ryzyko niespojnych tresci.
## Output
- Poprawiony flow zapisu w `ScontainersController`, ktory zawsze przekazuje poprawne `id` przy edycji
- Testy jednostkowe zabezpieczajace przed powrotem regresji
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Source Files
@autoload/admin/Controllers/ScontainersController.php
@admin/templates/components/form-edit.php
@autoload/admin/ViewModels/Forms/FormEditViewModel.php
@autoload/admin/Support/Forms/FormRequestHandler.php
@tests/Unit/admin/Controllers/ScontainersControllerTest.php
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
| Skill | Priority | When to Invoke | Loaded? |
|-------|----------|----------------|---------|
| /feature-dev | required | Before implementation in APPLY | ○ |
| /koniec-pracy | required | After implementation/release wrap-up | ○ |
**BLOCKING:** Required skills MUST be loaded before APPLY proceeds.
Run each skill command or confirm already loaded.
## Skill Invocation Checklist
- [ ] /feature-dev loaded (run command or confirm)
- [ ] /koniec-pracy loaded (run command or confirm)
</skills>
<acceptance_criteria>
## AC-1: Edycja nie tworzy nowego kontenera
```gherkin
Given istnieje kontener statyczny o ID 9
When admin wejdzie w /admin/scontainers/edit/id=9 i kliknie "Zatwierdz"
Then rekord o ID 9 zostanie zaktualizowany
And nie powstanie nowy rekord w pp_scontainers
```
## AC-2: Tworzenie nowego kontenera nadal dziala
```gherkin
Given admin otwiera /admin/scontainers/edit/ bez ID
When wypelni dane i kliknie "Zatwierdz"
Then zapis utworzy nowy rekord w pp_scontainers
```
## AC-3: API legacy JSON pozostaje bez zmian
```gherkin
Given zapis kontenera odbywa sie przez legacy payload values (JSON)
When wywolywana jest sciezka legacy w ScontainersController::save()
Then zachowanie insert/update pozostaje zgodne z dotychczasowa logika
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Utrwalic przekazywanie ID w nowym formularzu scontainers</name>
<files>autoload/admin/Controllers/ScontainersController.php</files>
<action>
W `buildFormViewModel()` przeniesc `id` do `hiddenFields` (FormEditViewModel),
tak aby pole `id` bylo renderowane niezaleznie od zakladek.
W `save()` dodac defensywny fallback: jesli `data['id']` z requestu jest puste,
pobrac `id` z parametru trasy (`Helpers::get('id')`) i uzyc go przy zapisie.
Nie zmieniac flow legacy (`values` JSON) ani logiki repozytorium.
</action>
<verify>Manual check: edycja /admin/scontainers/edit/id=9 aktualizuje rekord 9 zamiast tworzyc nowy</verify>
<done>AC-1 i AC-3 satisfied</done>
</task>
<task type="auto">
<name>Task 2: Dodac test regresyjny dla formularza i mapowania ID</name>
<files>tests/Unit/admin/Controllers/ScontainersControllerTest.php</files>
<action>
Rozszerzyc testy kontrolera o przypadki potwierdzajace, ze formularz edycji
niesie `id` jako hidden field oraz ze flow zapisu potrafi odczytac ID rekordu
dla przypadku edycji.
Uzyc Reflection tam, gdzie potrzeba dostepu do prywatnych metod (zgodnie z obecnym stylem testow).
</action>
<verify>./test.ps1 tests/Unit/admin/Controllers/ScontainersControllerTest.php</verify>
<done>AC-1 covered by automated tests</done>
</task>
<task type="auto">
<name>Task 3: Zweryfikowac brak regresji create flow</name>
<files>autoload/admin/Controllers/ScontainersController.php, tests/Unit/admin/Controllers/ScontainersControllerTest.php</files>
<action>
Potwierdzic, ze nowy kontener (brak `id` w URL i formularzu) nadal tworzy nowy rekord.
Dostosowac warunki fallbacku tak, by nie wymuszaly update przy create.
</action>
<verify>Manual check: /admin/scontainers/edit/ -> Zatwierdz tworzy nowe ID</verify>
<done>AC-2 satisfied</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- autoload/Domain/Scontainers/ScontainersRepository.php (brak zmian logiki insert/update na poziomie repo)
- admin/templates/components/form-edit.php (bez globalnych zmian w uniwersalnym komponencie)
- Inne kontrolery admin poza ScontainersController
## SCOPE LIMITS
- Zakres tylko dla problemu edycji kontenerow statycznych (scontainers)
- Bez refaktoryzacji calego systemu FormEdit
</boundaries>
<verification>
Before declaring plan complete:
- [ ] ./test.ps1 tests/Unit/admin/Controllers/ScontainersControllerTest.php
- [ ] Manual: edycja istniejacego kontenera nie tworzy nowego rekordu
- [ ] Manual: tworzenie nowego kontenera nadal dziala
- [ ] All acceptance criteria met
</verification>
<success_criteria>
- Blad edycji kontenerow statycznych nie wystepuje
- Test regresyjny przechodzi
- Brak regresji w create flow dla scontainers
</success_criteria>
<output>
After completion, create `.paul/phases/15-scontainers-edit-save-fix/15-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,108 @@
---
phase: 15-scontainers-edit-save-fix
plan: 01
subsystem: admin
tags: [scontainers, form-edit, hidden-fields, regression-fix]
requires: []
provides:
- Fix edycji scontainers (update zamiast insert)
- Regresyjne testy kontrolera dla mapowania id
affects: []
tech-stack:
added: []
patterns: [hiddenFields for stable id transfer in tabbed form-edit]
key-files:
created: []
modified:
- autoload/admin/Controllers/ScontainersController.php
- tests/Unit/admin/Controllers/ScontainersControllerTest.php
key-decisions:
- "Przeniesienie id z FormField::hidden do hiddenFields w FormEditViewModel"
- "Fallback id z route parametru przy zapisie edycji"
patterns-established:
- "W formularzach z zakladkami id encji przekazujemy przez hiddenFields, nie przez pola przypisane do taba"
duration: ~20min
completed: 2026-04-18
---
# Phase 15 Plan 01: Scontainers edit save fix - Summary
**Naprawiono regresje, przez ktora edycja kontenera statycznego tworzyla nowy rekord zamiast aktualizacji istniejacego ID.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~20min |
| Completed | 2026-04-18 |
| Tasks | 3 completed |
| Files modified | 2 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Edycja nie tworzy nowego kontenera | Pass | `id` jest zawsze przenoszone przez hiddenFields + fallback z URL przy braku w POST |
| AC-2: Tworzenie nowego kontenera nadal dziala | Pass | Dla create `id=0`, action pozostaje `/admin/scontainers/save/` |
| AC-3: API legacy JSON pozostaje bez zmian | Pass | Sciezka `values` (legacy) nie byla modyfikowana |
## Accomplishments
- Przeniesiono `id` do `hiddenFields` w `ScontainersController::buildFormViewModel()`, co eliminuje gubienie `id` w formularzu tabowanym.
- Dodano defensywny fallback na `id` z parametru trasy w `ScontainersController::save()`.
- Dodano 2 testy regresyjne dla mapowania `id` i create-flow.
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `autoload/admin/Controllers/ScontainersController.php` | Modified | Stabilne przekazywanie `id` dla update oraz fallback route `id` |
| `tests/Unit/admin/Controllers/ScontainersControllerTest.php` | Modified | Testy regresyjne dla hiddenFields i create flow |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Uzyc `hiddenFields` zamiast `FormField::hidden('id')` | Hidden field w tabbed form moze nie byc renderowany w aktywnej strukturze pol | Brak tworzenia duplikatow przy edycji |
| Dodac fallback `id` z URL w `save()` | Dodatkowa odpornosc na brak `id` w payloadzie | Bezpieczny update dla `/admin/scontainers/save/id={id}` |
## Deviations from Plan
Brak istotnych odchylen implementacyjnych.
Skill audit:
- `/feature-dev` - pominiety na prosbe uzytkownika (override zapisany w STATE.md)
- `/koniec-pracy` - wymaganie zmapowane na `.claude/commands/koniec-pracy.md`
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| Brak `test.ps1` w workspace | Test uruchomiony bezposrednio przez `php phpunit.phar ...` |
## Verification Results
- `php phpunit.phar tests/Unit/admin/Controllers/ScontainersControllerTest.php`
- Wynik: `OK (6 tests, 20 assertions)`
## Next Phase Readiness
**Ready:**
- Problem zapisu scontainers naprawiony na poziomie kontrolera.
- Testy regresyjne zabezpieczaja krytyczny przypadek.
**Concerns:**
- Manualna weryfikacja UI edycji/create nadal wskazana po stronie panelu admin.
**Blockers:**
- None.
---
*Phase: 15-scontainers-edit-save-fix, Plan: 01*
*Completed: 2026-04-18*

View File

@@ -0,0 +1,201 @@
---
phase: 16-product-list-custom-labels
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- autoload/admin/Controllers/ShopProductController.php
- autoload/Domain/Product/ProductRepository.php
- admin/templates/shop-product/products-list-custom-script.php
- tests/Unit/admin/Controllers/ShopProductControllerTest.php
- tests/Unit/Domain/Product/ProductRepositoryTest.php
autonomous: false
delegation: off
---
<objective>
## Goal
Dodac w liscie produktow (`/admin/shop_product/view_list/`) przelacznik "Pokaz etykiety niestandardowe", ktory zapisuje stan w sesji i po wlaczeniu pokazuje szybka edycje 5 pol `custom_label_0..4` z podpowiedziami.
## Purpose
Administrator ma szybciej uzupelniac etykiety Google XML bez wchodzenia do edycji kazdego produktu, z zachowaniem spojnosc danych i wygodnych podpowiedzi istniejacych wartosci.
## Output
- Nowy przycisk obok "Dodaj produkt", sterujacy widocznoscia custom labels i zapisujacy stan w sesji
- Render 5 pol `custom_label_0..4` pod nazwa/SKU produktu w tabeli, tylko przy wlaczonej opcji
- Zapis kazdego pola do `pp_shop_products` oraz system podpowiedzi z istniejacych wartosci w bazie
- Odczyt nazw etykiet z bazy (z fallbackiem) zamiast hardcodu w widoku listy
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Source Files
@autoload/admin/Controllers/ShopProductController.php
@autoload/Domain/Product/ProductRepository.php
@admin/templates/components/table-list.php
@admin/templates/shop-product/products-list.php
@admin/templates/shop-product/products-list-custom-script.php
@tests/Unit/admin/Controllers/ShopProductControllerTest.php
@tests/Unit/Domain/Product/ProductRepositoryTest.php
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
| Skill | Priority | When to Invoke | Loaded? |
|-------|----------|----------------|---------|
| /feature-dev | required | Before implementation in APPLY | ○ |
| /koniec-pracy | required | After implementation/release wrap-up | ○ |
**BLOCKING:** Required skills MUST be loaded before APPLY proceeds.
Run each skill command or confirm already loaded.
## Skill Invocation Checklist
- [ ] /feature-dev loaded (run command or confirm)
- [ ] /koniec-pracy loaded (run command or confirm)
</skills>
<acceptance_criteria>
## AC-1: Przelacznik widocznosci custom labels dziala i jest zapamietywany
```gherkin
Given administrator jest na /admin/shop_product/view_list/
When kliknie przycisk "Pokaz etykiety niestandardowe"
Then stan opcji zostanie zapisany w sesji
And po odswiezeniu/listowaniu tabela zachowa ustawiony stan (wlaczony lub wylaczony)
```
## AC-2: Lista produktow pokazuje 5 pol custom_label po wlaczeniu opcji
```gherkin
Given opcja "Pokaz etykiety niestandardowe" jest wlaczona
When lista produktow sie renderuje
Then pod sekcja zdjecie/nazwa/SKU-EAN dla kazdego produktu widoczne sa pola custom_label_0..custom_label_4
And etykiety tych pol sa pobrane dynamicznie z bazy danych (z fallbackiem tylko gdy brak konfiguracji)
```
## AC-3: Zapis i podpowiedzi wartosci dzialaja dla kazdego custom_label
```gherkin
Given administrator wpisuje wartosc w jednym z pol custom_label_0..custom_label_4
When wybierze podpowiedz lub zatwierdzi wpis
Then wartosc zostanie zapisana w pp_shop_products dla danego produktu i pola
And podpowiedzi sa budowane z juz istniejacych wartosci tego samego custom_label w bazie
```
## AC-4: Walidacja i bezpieczenstwo endpointow sa zachowane
```gherkin
Given zapytanie AJAX podaje nieprawidlowy typ labela spoza custom_label_0..4
When backend przetwarza request
Then operacja zostaje odrzucona bez zapisu
And odpowiedz zwraca status bledu bez ingerencji w dane produktu
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Dodac backend przelacznika sesyjnego i danych dla widoku listy</name>
<files>autoload/admin/Controllers/ShopProductController.php, autoload/Domain/Product/ProductRepository.php</files>
<action>
Rozszerzyc `ShopProductController::view_list()` o flage sesyjna dla widocznosci custom labels
oraz przekazanie do widoku nazw etykiet pobieranych z bazy.
Dodac akcje kontrolera do przelaczania flagi (toggle) i zwracania prostego JSON.
W `ProductRepository` dodac metode pobierajaca nazwy etykiet custom_label_0..4 z bazy
(np. tabela ustawien), z bezpiecznym fallbackiem "Custom label N" gdy wartosc nie istnieje.
Nie stosowac konkatenacji SQL dla danych wejsciowych.
</action>
<verify>Uruchomic testy kontrolera/repo oraz sprawdzic recznie, ze zmiana flagi utrzymuje sie po reloadzie listy</verify>
<done>AC-1 i AC-2 satisfied</done>
</task>
<task type="auto">
<name>Task 2: Dodac UI i logike AJAX dla custom labels na liscie produktow</name>
<files>autoload/admin/Controllers/ShopProductController.php, admin/templates/shop-product/products-list-custom-script.php</files>
<action>
Wygenerowac HTML 5 pol custom_label pod kolumna nazwy produktu tylko gdy flaga sesyjna jest wlaczona.
Uzyc klas zgodnych z istniejacym stylem (`custom-labels`, `custom_label_X_container`, listy sugestii).
Dodac przycisk "Pokaz etykiety niestandardowe" obok "Dodaj produkt" (hook przez custom script listy)
oraz obsluge klikniecia przez AJAX do nowej akcji toggle + odswiezenie aktualnego URL.
Podlaczyc dla kazdego inputa:
- pobieranie sugestii przez `/admin/shop_product/product_custom_label_suggestions/`
- zapis przez `/admin/shop_product/product_custom_label_save/`
z walidacja odpowiedzi i obsluga bledow UI.
</action>
<verify>Manual: wlaczyc opcje, wpisac i zapisac wartosc custom_label, odswiezyc strone, potwierdzic widocznosc i dane</verify>
<done>AC-2 i AC-3 satisfied</done>
</task>
<task type="auto">
<name>Task 3: Dodac testy regresyjne dla nowego zachowania</name>
<files>tests/Unit/admin/Controllers/ShopProductControllerTest.php, tests/Unit/Domain/Product/ProductRepositoryTest.php</files>
<action>
Rozszerzyc testy kontrolera o przypadki:
- toggle flagi sesyjnej
- odrzucenie nieprawidlowych typow labeli
- poprawne przekazanie danych do widoku listy przy wlaczonej opcji.
Rozszerzyc testy repozytorium o:
- pobieranie nazw custom labels z bazy z fallbackiem
- sugestie i zapis tylko dla dozwolonych label_type.
</action>
<verify>./test.ps1 tests/Unit/admin/Controllers/ShopProductControllerTest.php oraz ./test.ps1 tests/Unit/Domain/Product/ProductRepositoryTest.php</verify>
<done>AC-4 covered and AC-1..AC-3 protected by tests</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>Nowy przycisk sesyjny + szybka edycja custom labels 0..4 z podpowiedziami na liscie produktow</what-built>
<how-to-verify>
1. Otworz: /admin/shop_product/view_list/
2. Kliknij: "Pokaz etykiety niestandardowe"
3. Potwierdz: pola custom_label_0..4 pojawiaja sie pod nazwa produktu
4. Wpisz wartosc, wybierz podpowiedz i odswiez strone
5. Potwierdz: wartosc zostala zapisana i toggle pozostaje aktywny
</how-to-verify>
<resume-signal>Type "approved" to continue, or describe issues to fix</resume-signal>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Globalnych komponentow listy niezwiązanych z produktami (`admin/templates/components/table-list.php`) poza minimalnym, koniecznym hookiem
- Endpointow API (`autoload/api/*`)
- Logiki produktow frontendowych (`autoload/front/*`, `templates/shop-product/*`)
## SCOPE LIMITS
- Zakres ograniczony do admin listy produktow i quick-edit custom labels
- Bez migracji DB w tym planie (odczyt nazw z istniejacych danych konfiguracyjnych)
- Bez refaktoru calego modułu integracji Google XML
</boundaries>
<verification>
Before declaring plan complete:
- [ ] ./test.ps1 tests/Unit/admin/Controllers/ShopProductControllerTest.php
- [ ] ./test.ps1 tests/Unit/Domain/Product/ProductRepositoryTest.php
- [ ] Manual check: toggle zapisuje sie w sesji i zachowuje po reloadzie
- [ ] Manual check: podpowiedzi i zapis custom labels dzialaja dla 0..4
- [ ] All acceptance criteria met
</verification>
<success_criteria>
- Przycisk "Pokaz etykiety niestandardowe" dziala i przechowuje stan w sesji
- Lista produktow pokazuje i zapisuje custom_label_0..4 bez wejscia w edycje produktu
- Nazwy etykiet sa pobierane z bazy z fallbackiem
- Testy regresyjne dla backendu i repozytorium przechodza
</success_criteria>
<output>
After completion, create `.paul/phases/16-product-list-custom-labels/16-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,149 @@
---
phase: 16-product-list-custom-labels
plan: 01
subsystem: admin
tags: [shop-product, custom-label, session-toggle, autocomplete, quick-edit]
requires: []
provides:
- Szybka edycja custom_label_0..4 na liscie produktow
- Przelacznik widocznosci etykiet w sesji admina
- Podpowiedzi istniejacych wartosci + wpisywanie wartosci wlasnej w jednym polu
affects: [shop-product-list, google-xml-label-flow]
tech-stack:
added: []
patterns: [inline quick-edit in table list, datalist autocomplete in single input, settings fallback mapping]
key-files:
created: []
modified:
- autoload/admin/Controllers/ShopProductController.php
- autoload/Domain/Product/ProductRepository.php
- admin/templates/shop-product/products-list.php
- admin/templates/shop-product/products-list-custom-script.php
- tests/Unit/admin/Controllers/ShopProductControllerTest.php
- tests/Unit/Domain/Product/ProductRepositoryTest.php
key-decisions:
- "Przelacznik widocznosci custom labels zapisany w sesji (per admin session)"
- "Nazwy custom labels pobierane z pp_settings z fallbackiem do domyslnych nazw"
- "UX: jedno pole input z autocomplete (datalist), bez osobnego select pod spodem"
patterns-established:
- "Dla list admina: toggle funkcji przez dedykowany endpoint JSON + reload widoku"
- "Quick-edit text fields: walidacja typu po stronie kontrolera przed zapisem/sugestiami"
duration: ~120min
completed: 2026-04-19
---
# Phase 16 Plan 01: Product list custom labels quick edit - Summary
**Wdrozono szybka edycje custom labels na liscie produktow z przelacznikiem sesyjnym i autocomplete w pojedynczym polu.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~120min |
| Completed | 2026-04-19 |
| Tasks | 3 completed + 1 checkpoint approved |
| Files modified | 6 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Przelacznik widocznosci custom labels dziala i jest zapamietywany | Pass | Dodano przycisk toggle + zapis stanu w sesji admina |
| AC-2: Lista pokazuje 5 pol custom_label po wlaczeniu opcji | Pass | Pola custom_label_0..4 renderowane pod nazwa/SKU/EAN tylko przy wlaczonej opcji |
| AC-3: Zapis i podpowiedzi wartosci dzialaja dla custom_label | Pass | Zapis AJAX do bazy + autocomplete istniejacych wartosci i mozliwosc wpisu wlasnego |
| AC-4: Walidacja i bezpieczenstwo endpointow zachowane | Pass | Kontroler odrzuca niedozwolone label_type przed zapisem i pobraniem sugestii |
## Accomplishments
- Dodano nowy toggle "Pokaz/Ukryj etykiety niestandardowe" przy liscie produktow, sterowany sesja.
- Dodano inline quick-edit custom_label_0..4 bez wchodzenia do edycji produktu.
- Podpowiedzi dzialaja jako autocomplete w tym samym input (nie osobny kontrolka pod polem).
- Ujednolicono UX przycisku toggle (kolorystyka, rozmiar, hover, czytelnosc).
- Rozszerzono testy kontrolera i repozytorium o nowe przypadki.
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `autoload/admin/Controllers/ShopProductController.php` | Modified | Toggle sesyjny, render custom labels w tabeli, walidacja label_type |
| `autoload/Domain/Product/ProductRepository.php` | Modified | Pobieranie nazw custom_label z ustawien (fallback) |
| `admin/templates/shop-product/products-list.php` | Modified | Przekazanie flagi custom_labels_enabled do skryptu |
| `admin/templates/shop-product/products-list-custom-script.php` | Modified | UI toggle, zapis/sugestie custom label, autocomplete, poprawki wygladu |
| `tests/Unit/admin/Controllers/ShopProductControllerTest.php` | Modified | Testy nowych metod i walidacji kontrolera |
| `tests/Unit/Domain/Product/ProductRepositoryTest.php` | Modified | Testy customLabelNames + walidacji sugestii/zapisu |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Session key dla toggla (`shop_product_show_custom_labels`) | Funkcja ma byc osobnym trybem widoku admina | Stabilny stan po odswiezeniu strony |
| Nazwy etykiet z `pp_settings` + fallback | Wymaganie "nazwy z bazy" i bezpieczne zachowanie gdy brak konfiguracji | Elastyczne nazewnictwo bez hardcodu |
| Autocomplete w jednym polu zamiast input + oddzielny select | UX feedback od usera podczas checkpointu | Czytelniejszy i szybszy flow edycji |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Auto-fixed | 1 | Niski, techniczny (BOM w pliku kontrolera) |
| Scope additions | 2 | Niski, UX polish po feedbacku checkpoint |
| Deferred | 0 | Brak |
**Total impact:** Niezbedne poprawki techniczne i UX bez scope creep funkcjonalnego.
### Auto-fixed Issues
**1. Encoding/BOM in controller file**
- **Found during:** Task 1 implementation verification
- **Issue:** Parser PHP zglaszal blad namespace przez BOM na poczatku pliku
- **Fix:** Zapisano plik `ShopProductController.php` jako UTF-8 bez BOM
- **Files:** `autoload/admin/Controllers/ShopProductController.php`
- **Verification:** `php -l autoload/admin/Controllers/ShopProductController.php`
### Deferred Items
None.
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| Drobne regresje UX przycisku toggle | Iteracyjna poprawka stylu i hover po feedbacku usera |
| Forma podpowiedzi (select pod inputem) nieakceptowalna UX | Zmieniono na jedno pole z autocomplete (datalist) |
## Verification Results
- `php -l autoload/admin/Controllers/ShopProductController.php` -> OK
- `php -l autoload/Domain/Product/ProductRepository.php` -> OK
- `php -l admin/templates/shop-product/products-list-custom-script.php` -> OK
- `php phpunit.phar tests/Unit/admin/Controllers/ShopProductControllerTest.php` -> OK (15 tests, 71 assertions)
- `php phpunit.phar tests/Unit/Domain/Product/ProductRepositoryTest.php` -> OK (64 tests, 131 assertions)
- Checkpoint human-verify: approved by user after final UX adjustments
Skill audit:
- `/feature-dev` - not invoked (user-approved override)
- `/koniec-pracy` - acknowledged by user as available for end-of-session flow
## Next Phase Readiness
**Ready:**
- Admin ma szybki i praktyczny workflow uzupelniania custom labels bez przechodzenia do edycji produktu.
- Kod posiada testy regresyjne dla nowej logiki backendowej.
**Concerns:**
- Brak.
**Blockers:**
- None.
---
*Phase: 16-product-list-custom-labels, Plan: 01*
*Completed: 2026-04-19*

View File

@@ -0,0 +1,191 @@
---
phase: 17-cart-summary-transport-cost-fix
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- autoload/front/Controllers/ShopBasketController.php
- templates/shop-basket/summary-view.php
- tests/Unit/front/Controllers/ShopBasketControllerSummaryViewTest.php
autonomous: false
delegation: off
---
<objective>
## Goal
Naprawic blad na stronie /koszyk-podsumowanie, gdzie wybrana forma wysylki oraz laczna kwota zamowienia pokazywane sa za 0,00 zl, mimo ze koszyk nie osiagnal progu darmowej dostawy.
## Purpose
Klient widzi nieprawidlowe podsumowanie zamowienia. Koszt transportu w szablonie summary-view.php jest redukowany do zera zawsze, gdy transport ma flage `delivery_free = 1`, bez sprawdzenia czy wartosc koszyka przekroczyla prog `$settings['free_delivery']`. W efekcie klient widzi "0,00 zl" i zaniżona sume zamowienia. Po zlozeniu zamowienia dane w bazie i ostatecznej cenie moga sie roznic, co psuje zaufanie i ksiegowosc.
## Output
- ShopBasketController::summaryView() przekazuje do szablonu koszt transportu po uwzglednieniu progu darmowej dostawy (nowy klucz `transport_cost_effective` oraz `free_delivery_applies`).
- Szablon summary-view.php pokazuje koszt transportu i sume koncowa na podstawie tych kluczy zamiast surowej flagi `delivery_free`.
- Nowy test jednostkowy potwierdza logike wyliczania kosztu w kontrolerze dla 3 scenariuszy (basket ponizej progu, basket rowny progowi, transport bez `delivery_free`).
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Source Files
@autoload/front/Controllers/ShopBasketController.php
@autoload/Domain/Transport/TransportRepository.php
@autoload/Domain/Basket/BasketCalculator.php
@templates/shop-basket/summary-view.php
@templates/shop-basket/basket-transport-methods.php
## Powiazane pliki (do odwolania)
- settings.free_delivery w `pp_settings` (globalny prog darmowej dostawy)
- Helpers::normalize_decimal / Helpers::decimal (format kwot)
</context>
<acceptance_criteria>
## AC-1: Transport z flaga delivery_free ponizej progu pokazuje rzeczywisty koszt
```gherkin
Given transport ma `delivery_free = 1`, cost = 15.00 zl, a `$settings['free_delivery']` = 300 zl
And wartosc koszyka (po kuponie) wynosi 150 zl
When klient wchodzi na /koszyk-podsumowanie
Then linia transportu pokazuje "15,00 zl"
And laczna kwota zamowienia zawiera te 15,00 zl
```
## AC-2: Transport z flaga delivery_free powyzej progu pokazuje 0,00 zl
```gherkin
Given transport ma `delivery_free = 1`, cost = 15.00 zl, a `$settings['free_delivery']` = 300 zl
And wartosc koszyka (po kuponie) wynosi 350 zl
When klient wchodzi na /koszyk-podsumowanie
Then linia transportu pokazuje "0,00 zl"
And laczna kwota nie zawiera kosztu transportu
```
## AC-3: Transport bez flagi delivery_free zawsze pokazuje swoj koszt
```gherkin
Given transport ma `delivery_free = 0`, cost = 25.00 zl
And wartosc koszyka wynosi 500 zl (powyzej dowolnego progu)
When klient wchodzi na /koszyk-podsumowanie
Then linia transportu pokazuje "25,00 zl"
And laczna kwota zawiera te 25,00 zl
```
## AC-4: Suma testow PHPUnit nie maleje, nowy test zielony
```gherkin
Given istniejacy zestaw testow `./test.ps1`
When uruchamiam pelna suite
Then nowy test `ShopBasketControllerSummaryViewTest` przechodzi
And zadne istniejace testy nie zaczynaja failowac
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Przekaz wyliczony koszt transportu do summary-view z kontrolera</name>
<files>autoload/front/Controllers/ShopBasketController.php</files>
<action>
W metodzie `summaryView()` (ok. linia 270):
- Po pobraniu `$transport` (findActiveByIdCached) wylicz kwote koszyka po kuponie uzywajac `\Domain\Basket\BasketCalculator::summaryPrice( $basket, $coupon )` - tak jak robi to `transportMethodsFront`.
- Wczytaj `$settings['free_delivery']` z globala `$settings`.
- Ustaw `free_delivery_applies = false` gdy transport nie istnieje; w przeciwnym razie `true` wtedy i tylko wtedy gdy `$transport['delivery_free'] == 1` ORAZ `normalize_decimal($products_summary) >= normalize_decimal($settings['free_delivery'])`.
- Wylicz `transport_cost_effective` = `free_delivery_applies ? 0.0 : (float)$transport['cost']`.
- Do `Tpl::view` przekaz dodatkowe klucze `transport_cost_effective` i `free_delivery_applies`.
Nie zmieniaj istniejacych kluczy (transport, payment_method itd.) zeby nie zepsuc innych uzyc szablonu.
Nie modyfikuj logiki tokenu zamowienia ani guardow.
Unikaj: dodawania nowych metod do TransportRepository (kalkulacja nalezy do warstwy koszyka, nie transportu).
</action>
<verify>Recznie odczytaj plik, upewnij sie ze dane sa w tablicy Tpl::view i sa uzywane deterministycznie dla transport === null.</verify>
<done>AC-1, AC-2 i AC-3 zaspokojone po stronie danych; AC-4 kontrolera.</done>
</task>
<task type="auto">
<name>Task 2: Zaktualizuj summary-view.php aby uzywal wyliczonych kluczy</name>
<files>templates/shop-basket/summary-view.php</files>
<action>
W bloku "basket-summary" (ok. linii 97-115):
- Zamien warunek `$this->transport['delivery_free'] == 1` na `$this->free_delivery_applies`.
- Zamiast `$this->transport['cost']` wyswietlaj `$this->transport_cost_effective` w galezi "else" oraz w kwocie koncowej.
- Linia koncowej kwoty (order-summary): `$this->free_delivery_applies ? decimal($summary) : decimal($summary + $this->transport_cost_effective)`.
- Zadbaj o poprawne wyswietlenie gdy `transport` jest `null` (skeleton: zachowaj stary fallback - brak kosztu dodawanego).
Nie modyfikuj pozostalych fragmentow (produkty, adres, GTM itd.).
Unikaj: duplikowania logiki progu w szablonie - szablon ma wyswietlac, nie liczyc.
</action>
<verify>W szablonie nie wystepuje juz `$this->transport['delivery_free']` w tym bloku; nowe klucze sa uzyte dwukrotnie (linia transportu + suma).</verify>
<done>AC-1, AC-2, AC-3 zaspokojone po stronie prezentacji.</done>
</task>
<task type="auto">
<name>Task 3: Test jednostkowy dla logiki kontrolera</name>
<files>tests/Unit/front/Controllers/ShopBasketControllerSummaryViewTest.php</files>
<action>
Utworz nowy plik testow PHPUnit extending `PHPUnit\Framework\TestCase`.
Testuj publiczna metode pomocnicza lub kalkulacje w `summaryView()` przez refleksje/helper - preferowane: wyodrebnic wyliczenie do prywatnej metody i wyeksponowac prywatna metode przez ReflectionMethod (bez zmiany publicznego API).
Alternatywa: utworz w kontrolerze protected method `calculateTransportCostForSummary(array $transport = null, array $basket, $coupon, float $freeDeliveryThreshold): array` zwracajaca `['transport_cost_effective' => float, 'free_delivery_applies' => bool]` i pokryj ja testami bezposrednio.
Trzy scenariusze (AC-1, AC-2, AC-3) + czwarty: transport === null -> cost 0.0, applies false.
Mock `\Domain\Basket\BasketCalculator::summaryPrice` nie jest wymagany - podaj gotowa liczbe w tescie.
Test musi sie uruchamiac pod `./test.ps1`.
Unikaj: testowania na realnej bazie - stub Medoo zaslugujac na AAA.
</action>
<verify>Uruchom `./test.ps1 tests/Unit/front/Controllers/ShopBasketControllerSummaryViewTest.php` - 4 testy zielone. Potem `./test.ps1` pelna suite - liczba testow >= 825 (pelna, bez regresji).</verify>
<done>AC-4 zaspokojone.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
Poprawka koszt transportu na /koszyk-podsumowanie.
</what-built>
<how-to-verify>
1. W panelu admina upewnij sie, ze co najmniej jedna metoda transportu ma `delivery_free = 1` i niezerowy `cost` (np. 15 zl).
2. Ustaw `settings.free_delivery` na np. 300 zl.
3. Dodaj do koszyka produkty o wartosci PONIZEJ progu (np. 150 zl).
4. Wejdz na /koszyk, wybierz transport z `delivery_free = 1`, przejdz do /koszyk-podsumowanie.
5. Potwierdz, ze linia transportu pokazuje "15,00 zl" (nie "0,00 zl") i suma zawiera ten koszt.
6. Dolow koszyk do wartosci POWYZEJ progu (>300 zl), odswiez /koszyk-podsumowanie.
7. Potwierdz, ze linia transportu pokazuje "0,00 zl" i suma NIE zawiera kosztu transportu.
8. Wybierz transport bez `delivery_free = 1` (np. kurier 25 zl), potwierdz ze zawsze pokazuje 25,00 zl.
</how-to-verify>
<resume-signal>Wpisz "approved" aby zakonczyc, lub opisz niezgodnosc do poprawy.</resume-signal>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- autoload/Domain/Transport/TransportRepository.php (kalkulacja kosztu transportu juz jest w `transportMethodsFront`; nie duplikujemy logiki tam).
- autoload/Domain/Basket/BasketCalculator.php (wyliczenie wartosci koszyka pozostaje bez zmian).
- templates/shop-basket/basket-transport-methods.php (lista metod na /koszyk dziala poprawnie).
- Logika tokenu zamowienia w ShopBasketController (createOrderSubmitToken, consumeOrderSubmitToken).
- Struktura bazy danych (brak migracji).
## SCOPE LIMITS
- Plan naprawia WYLACZNIE wyswietlanie kosztu na /koszyk-podsumowanie.
- Nie refaktoryzujemy summary-view.php poza blokiem transportu.
- Nie zmieniamy mechanizmu cache transportu.
- Nie dodajemy nowych ustawien/kolumn w bazie.
</boundaries>
<verification>
Przed zamknieciem planu:
- [ ] `./test.ps1` pelna suite zielona (wszystkie >=824 + 4 nowe testy).
- [ ] Recznie zweryfikowano 3 scenariusze na /koszyk-podsumowanie (checkpoint human-verify).
- [ ] W summary-view.php nie wystepuje juz `$this->transport['delivery_free']` w sekcji podsumowania.
- [ ] Nowy plik testu istnieje i jest w strukturze `tests/Unit/front/Controllers/`.
- [ ] Kod zgodny z PHP 7.4 (brak `match`, named arguments itd.).
</verification>
<success_criteria>
- Wszystkie 4 AC zaspokojone.
- Suita PHPUnit zielona bez regresji.
- Checkpoint human-verify zaakceptowany.
- Brak nowych ostrzezen/bledow w logach.
</success_criteria>
<output>
Po zakonczeniu utworz `.paul/phases/17-cart-summary-transport-cost-fix/17-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,150 @@
---
phase: 17-cart-summary-transport-cost-fix
plan: 01
subsystem: checkout
tags: [basket, transport, free-delivery, summary-view, php74]
requires:
- phase: 13-basket-logging-ttl-token
provides: createOrderSubmitToken + TTL i logging w basketSave
provides:
- Poprawna kalkulacja kosztu transportu na /koszyk-podsumowanie
- Testowalna chroniona metoda ShopBasketController::calculateTransportCostForSummary
affects: [przyszle zmiany checkoutu, kupony, promocje darmowej dostawy]
tech-stack:
added: []
patterns:
- "Logika prezentacyjna kosztu transportu trzymana w kontrolerze, nie w szablonie"
- "Chronione metody pomocnicze testowane przez ReflectionMethod"
key-files:
created:
- tests/Unit/front/Controllers/ShopBasketControllerSummaryViewTest.php
modified:
- autoload/front/Controllers/ShopBasketController.php
- templates/shop-basket/summary-view.php
key-decisions:
- "Kalkulacja kosztu transportu zostaje w warstwie kontrolera (summaryView), nie w TransportRepository — Repository dostarcza dane, kontroler interpretuje je dla konkretnego widoku"
- "Metoda calculateTransportCostForSummary pozostaje protected i jest testowana przez Reflection (public API kontrolera bez zmian)"
patterns-established:
- "Szablon summary-view otrzymuje gotowe klucze prezentacyjne (transport_cost_effective, free_delivery_applies) zamiast liczyc progi w locie"
duration: ~25min
started: 2026-04-20T00:00:00Z
completed: 2026-04-20T00:25:00Z
---
# Phase 17 Plan 01: Cart summary transport cost fix — Summary
**Na /koszyk-podsumowanie wybrany transport z flaga delivery_free=1 pokazuje teraz rzeczywisty koszt ponizej progu darmowej dostawy i 0,00 zl powyzej progu — zgodnie z logika listy transportow na /koszyk.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~25 min |
| Started | 2026-04-20 |
| Completed | 2026-04-20 |
| Tasks | 4 completed (3 auto + 1 checkpoint) |
| Files modified | 2 modified + 1 created |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Transport z delivery_free ponizej progu pokazuje rzeczywisty koszt | Pass | Test `testTransportWithDeliveryFreeBelowThresholdShowsRealCost` + manualna weryfikacja |
| AC-2: Transport z delivery_free powyzej progu pokazuje 0,00 zl | Pass | Test `testTransportWithDeliveryFreeAboveThresholdShowsZero` + manualna weryfikacja |
| AC-3: Transport bez flagi delivery_free zawsze pokazuje koszt | Pass | Test `testTransportWithoutDeliveryFreeAlwaysShowsCost` + manualna weryfikacja |
| AC-4: Suita PHPUnit zielona, nowy test przechodzi | Pass | 834/834 OK, 2318 assertions (6 nowych testow) |
## Accomplishments
- Chroniona metoda `ShopBasketController::calculateTransportCostForSummary()` enkapsuluje regule progowa darmowej dostawy i jest czysto testowalna.
- Szablon `summary-view.php` pozbyty dwoch duplikatow logiki `delivery_free == 1` — uzywa teraz gotowych kluczy widokowych.
- 6 testow jednostkowych pokrywa 3 AC i 3 edge case'y (transport null, prog 0, wartosc koszyka rowna progowi).
- Pelna suita zgadza sie z docs/MEMORY.md (>800 testow, 821 -> 834 po fazie).
## Task Commits
Commit transition-phase jeszcze nie wykonany w tym UNIFY (patrz Deviations).
| Task | Commit | Type | Description |
|------|--------|------|-------------|
| Task 1: Calc effective cost w kontrolerze | (pending) | fix | ShopBasketController::summaryView + calculateTransportCostForSummary |
| Task 2: summary-view.php uzywa nowych kluczy | (pending) | fix | Usuniety odwolanie do transport.delivery_free w bloku podsumowania |
| Task 3: Nowy test jednostkowy | (pending) | test | ShopBasketControllerSummaryViewTest (6 testow) |
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `autoload/front/Controllers/ShopBasketController.php` | Modified | Dodana protected method calculateTransportCostForSummary; summaryView przekazuje transport_cost_effective + free_delivery_applies |
| `templates/shop-basket/summary-view.php` | Modified | Wiersz kosztu transportu i suma koncowa uzywaja nowych kluczy zamiast transport.delivery_free |
| `tests/Unit/front/Controllers/ShopBasketControllerSummaryViewTest.php` | Created | 6 testow jednostkowych dla logiki kalkulacji kosztu |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Kalkulacja w kontrolerze, nie w TransportRepository | Repository juz ma `transportMethodsFront` robiace to samo, ale dla listy metod — dla pojedynczego wybranego transportu to decyzja widokowa nalezaca do kontrolera | Brak zmiany publicznego API Repository |
| protected + Reflection do testow | Zgodne z istniejacym wzorcem `ShopBasketControllerTest` (Reflection), nie rozszerza publicznego API | Test izolowany od sesji i globali |
| Boundary na prog > 0 | Jesli `settings.free_delivery = 0`, darmowa dostawa jest wylaczona (brak progu = brak regul) | Ochrona przed niezamierzonym zerowaniem kosztu w sklepach bez tej funkcji |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Auto-fixed | 0 | — |
| Scope additions | 2 | 2 dodatkowe edge-case testy (boundary rowny prog + prog 0) |
| Deferred | 1 | Git commit transition-phase do wykonania w transition-phase lub rece |
**Total impact:** Bez scope creepu; dodatki to defensywne testy edge-case'ow.
### Auto-fixed Issues
None.
### Scope Additions
**1. Test dla wartosci koszyka rownej progowi**
- **Found during:** Task 3 (test jednostkowy)
- **Issue:** Plan AC-2 mowi "powyzej progu", granica rowna progowi nie byla pokryta
- **Fix:** Dodany `testTransportWithDeliveryFreeAtExactThresholdShowsZero`
- **Rationale:** Stare `transportMethodsFront` uzywa `>=` — utrzymana spojnosc
**2. Test dla settings.free_delivery = 0**
- **Found during:** Task 1 (implementacja)
- **Issue:** Sklepy bez ustawionego progu darmowej dostawy nie mogly miec zerowanych transportow; guard na > 0 wart pokrycia testem
- **Fix:** Dodany `testZeroFreeDeliveryThresholdDisablesFreeDelivery`
### Deferred Items
- Transition-phase git commit do uruchomienia w ramach `/paul:transition` lub recznego commita (spojne z historycznym wzorcem faz 15 i 16).
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| `test.ps1` nie istnieje w repo (pomimo wzmianki w CLAUDE.md) | Uruchomiono phpunit.phar bezposrednio przez `C:/xampp/php/php.exe phpunit.phar -c phpunit.xml` |
## Next Phase Readiness
**Ready:**
- Logika kosztu transportu w checkoutu spojna miedzy /koszyk i /koszyk-podsumowanie.
- Pelna suita zielona.
**Concerns:**
- Git commit nie wykonany automatycznie — nalezy domknac w transition-phase.
- CLAUDE.md odwoluje sie do `./test.ps1` ktorego nie ma w repo — do rozwazenia porzadkowo.
**Blockers:**
- None.
---
*Phase: 17-cart-summary-transport-cost-fix, Plan: 01*
*Completed: 2026-04-20*

View File

@@ -0,0 +1,258 @@
---
phase: 18-google-feed-permutation-url-fix
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- autoload/Domain/Product/ProductRepository.php
- autoload/Shared/Helpers/Helpers.php
- autoload/front/LayoutEngine.php
- templates/shop-product/_partial/product-attribute.php
- tests/Unit/Domain/Product/ProductRepositoryTest.php
- tests/Unit/Shared/Helpers/HelpersTest.php
autonomous: true
delegation: off
---
<objective>
## Goal
Naprawić linki produktów z permutacją atrybutów w feedzie Google: zamienić separator `/` na `_` między parami `attr-val`, dopasować routing `pp_routes`, konwersję `_``|` w warstwie front oraz preselekcję wartości atrybutów na podstawie `permutation_hash` z URL.
## Purpose
URL z formatu `/slug/20-170/21-175` nie matchował się w `pp_routes` (wzorzec `[0-9-]+` nie obejmuje `/`), więc Google Merchant Center prowadził klientów na stronę główną zamiast na produkt z wybraną kombinacją atrybutów. Strata ruchu komercyjnego z feedu.
## Output
- 4 pliki silnika z nowym separatorem `_`
- Unit testy: regex routingu (Helpers) + generator linku (ProductRepository::appendCombinationToXml)
- SUMMARY z listą akcji post-deploy do wykonania ręcznie na produkcji
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
@.paul/codebase/architecture.md
## Source Files
@autoload/Domain/Product/ProductRepository.php
@autoload/Shared/Helpers/Helpers.php
@autoload/front/LayoutEngine.php
@templates/shop-product/_partial/product-attribute.php
<clarifications>
- **Testy** — Czy dodać unit testy dla zmian?
→ Odpowiedź: Tak — pełne pokrycie (Helpers regex + ProductRepository::appendCombinationToXml)
- **Post-deploy** — Czy wykonać regenerację routes/cache/feedu w ramach fazy?
→ Odpowiedź: Nic — tylko kod; akcje produkcyjne udokumentowane w SUMMARY
- **Redirect 301** — Czy dodać redirecty ze starych URL-i?
→ Odpowiedź: Nie — Google sam zaktualizuje linki z feedu
- **Skills** — /feature-dev required w SPECIAL-FLOWS?
→ Odpowiedź: Override — pomiń (hotfix z konkretną instrukcją, jak w fazach 15/16/17)
</clarifications>
</context>
<acceptance_criteria>
## AC-1: Generator linku w feedzie używa `_`
```gherkin
Given produkt z permutacją atrybutów (permutation_hash = "20-170|21-175")
When wywołany jest ProductRepository::appendCombinationToXml dla feedu Google
Then wygenerowany URL zawiera segment `20-170_21-175` (jeden segment, separator `_`)
And nie zawiera `/` między parami atrybutów
And dotyczy obu gałęzi (z seo_link i fallback p-id-name)
```
## AC-2: Routing `pp_routes` matchuje URL z `_`
```gherkin
Given wzorzec routingu wygenerowany przez Helpers dla produktu z permutacją
When URI to `slug-produktu/20-170_21-175`
Then regex `[0-9_-]+` dopasowuje cały segment permutacji
And `permutation_hash` w wynikowych GET to `20-170_21-175`
And dotyczy obu wariantów (z seo_link i fallback p-id-name)
```
## AC-3: Front konwertuje `_` z URL na `|` przed zapytaniem do bazy
```gherkin
Given GET['permutation_hash'] = "20-170_21-175"
When LayoutEngine renderuje blok PRODUKT
Then ProductRepository::findCached otrzymuje argument "20-170|21-175"
And gdy GET['permutation_hash'] nie istnieje, findCached otrzymuje null
```
## AC-4: Partial atrybutu preselectuje wartość z URL
```gherkin
Given URL produktu z permutation_hash zawierającym parę dla bieżącego atrybutu
When renderuje się templates/shop-product/_partial/product-attribute.php
Then aktywna (checked) jest wartość z URL, nie z is_default
And gdy atrybut nie występuje w hashu, zachowane jest stare zachowanie (is_default)
And blok <script> z fradio_label_click() emitowany jest dla wartości z URL
```
## AC-5: Pełna suita testów zielona
```gherkin
Given wprowadzone zmiany w 4 plikach + 2 nowe/zaktualizowane testy
When uruchomiony jest ./test.ps1
Then wszystkie testy przechodzą (>=836 834 obecnych + 2 nowe)
And brak warningów PHP
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Zmiana separatora w generatorze feedu i routingu</name>
<files>autoload/Domain/Product/ProductRepository.php, autoload/Shared/Helpers/Helpers.php, autoload/front/LayoutEngine.php</files>
<action>
1. **autoload/Domain/Product/ProductRepository.php** — w `appendCombinationToXml` (~linie 2372 i 2374):
- Zamienić `str_replace('|', '/', $combination['permutation_hash'])` na `str_replace('|', '_', $combination['permutation_hash'])`
- Dotyczy OBU gałęzi (seo_link i fallback `p-id-name`)
- Najpierw przeczytać metodę i potwierdzić obie wystąpienia przed edycją
2. **autoload/Shared/Helpers/Helpers.php** — w generatorze tras (~linie 694 i 699):
- Rozszerzyć regex z `'^' . ... . '/([0-9-]+)$'` na `'^' . ... . '/([0-9_-]+)$'`
- Dotyczy OBU wariantów (seo_link i fallback `p-id-name`)
3. **autoload/front/LayoutEngine.php** — w bloku `// PRODUKT` (~linia 196):
- Wyciągnąć `permutation_hash` do zmiennej z konwersją `_``|`:
```php
$permutation_hash = isset($_GET['permutation_hash']) ? str_replace('_', '|', $_GET['permutation_hash']) : null;
```
- Przekazać `$permutation_hash` do `findCached()` zamiast inline `$_GET['permutation_hash'] ?? null`
Avoid:
- Zmian w `findCached()` lub `permutation_hash` w bazie — separator w DB pozostaje `|`
- Modyfikacji innych metod ProductRepository
- PHP 8.0+ syntaxu (`match`, named args)
</action>
<verify>
- `grep -n "str_replace.*'|'.*'/'" autoload/Domain/Product/ProductRepository.php` — brak wyników (0 wystąpień)
- `grep -n "str_replace.*'|'.*'_'" autoload/Domain/Product/ProductRepository.php` — 2 wystąpienia
- `grep -n "\[0-9_-\]+" autoload/Shared/Helpers/Helpers.php` — 2 wystąpienia
- `grep -n "\[0-9-\]+\\\$" autoload/Shared/Helpers/Helpers.php` — brak (stary wzorzec usunięty z generatora produktów z permutacją)
- `grep -n "permutation_hash" autoload/front/LayoutEngine.php` — zmienna wyciągnięta przed `findCached`
</verify>
<done>AC-1, AC-2, AC-3 spełnione</done>
</task>
<task type="auto">
<name>Task 2: Preselekcja atrybutu z permutation_hash w partialu</name>
<files>templates/shop-product/_partial/product-attribute.php</files>
<action>
Najpierw przeczytać cały plik partiala (mały, ~kilkadziesiąt linii) i zlokalizować pętlę `foreach` po `values` oraz miejsca używające `$value['is_default']`.
Na początku partiala (przed pętlą po values) dodać:
```php
$forced_value_id = null;
if ( isset( $_GET['permutation_hash'] ) && $_GET['permutation_hash'] !== '' )
{
$pairs = explode( '|', str_replace( '_', '|', $_GET['permutation_hash'] ) );
foreach ( $pairs as $pair )
{
$parts = explode( '-', $pair );
if ( count( $parts ) == 2 && (int)$parts[0] === (int)$this -> attribute['id'] )
{
$forced_value_id = (int)$parts[1];
break;
}
}
}
```
W pętli foreach po values, przed użyciem flagi `is_default`, policzyć:
```php
$is_active = $forced_value_id !== null
? ( (int)$value['id'] === $forced_value_id )
: (bool)$value['is_default'];
```
Zastąpić WSZYSTKIE użycia `$value['is_default']` w kontekście aktywności (checked, fradio_label_click) zmienną `$is_active`. Nie ruszać `is_default` jeśli używane gdzie indziej semantycznie (np. atrybut metadata).
Avoid:
- Modyfikacji `templates_user/` (potwierdzono: nie istnieje w tym repo)
- Zmian struktury HTML / klas CSS
- PHP 8.0+ syntaxu
</action>
<verify>
- `grep -n "forced_value_id" templates/shop-product/_partial/product-attribute.php` — co najmniej 4 wystąpienia (deklaracja, set, użycie w `$is_active`, użycie w `$is_active`)
- `grep -n "is_active" templates/shop-product/_partial/product-attribute.php` — co najmniej 2 wystąpienia (deklaracja + użycie w checked/script)
- Manualnie potwierdzić: `checked="checked"` używa `$is_active`, `fradio_label_click(...)` script gate'owany przez `$is_active`
</verify>
<done>AC-4 spełnione</done>
</task>
<task type="auto">
<name>Task 3: Unit testy dla regex routingu i generatora linku</name>
<files>tests/Unit/Shared/Helpers/HelpersTest.php, tests/Unit/Domain/Product/ProductRepositoryTest.php</files>
<action>
Najpierw sprawdzić strukturę istniejących testów (szczególnie czy `HelpersTest.php` istnieje — jeśli nie, utworzyć z bootstrapem zgodnym z innymi testami w `tests/Unit/Shared/`).
1. **Helpers — test regex routingu z `_`:**
- Wywołać generator tras dla produktu z permutacją (jeśli metoda jest publiczna; w przeciwnym razie test integracyjny z mockiem `$mdb` zwracającym permutacje produktu)
- Zweryfikować że wygenerowany pattern zawiera `[0-9_-]+` zamiast `[0-9-]+`
- Test: `preg_match` na patternie z URI `slug/20-170_21-175` zwraca true i wyciąga `20-170_21-175` jako capture group
- Test negatywny: pattern NIE matchuje `slug/20-170/21-175` (stary format ze slashem — chcemy 404, nie przypadkowy match)
2. **ProductRepository::appendCombinationToXml — test separatora `_`:**
- Może być nieosiągalna metoda private/protected. Strategia A (preferowana): jeśli private, użyć ReflectionMethod do wywołania na instancji z mockiem `$mdb`. Strategia B: jeśli zbyt skomplikowane, dodać minimalny test który wywołuje publiczną metodę feedu z mockiem i sprawdza wygenerowany XML.
- Mock combination z `permutation_hash = '20-170|21-175'`, `seo_link = 'jakas-fraza'`
- Asercja: w wygenerowanym XML link zawiera `jakas-fraza/20-170_21-175`, NIE zawiera `20-170/21-175`
- Drugi test: gałąź fallback (brak `seo_link`) — link `p-{id}-{name}/20-170_21-175`
Trzymać się konwencji: AAA, mock Medoo (`$this->createMock(\medoo::class)`), namespace tests jak w istniejących plikach. Brak PHP 8.0+ syntaxu. Nazwy metod z `test` prefiksem.
</action>
<verify>
- `./test.ps1 tests/Unit/Shared/Helpers/HelpersTest.php` — przechodzi
- `./test.ps1 tests/Unit/Domain/Product/ProductRepositoryTest.php` — przechodzi (wszystkie testy, łącznie z nowymi)
- `./test.ps1` — pełna suita zielona, count >= 836
</verify>
<done>AC-5 spełnione (testy zielone, ≥2 nowe testy pokrywające AC-1 i AC-2)</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Format `permutation_hash` w bazie (kolumna `pp_shop_product_combinations.permutation_hash` pozostaje z separatorem `|`)
- Sygnatura `ProductRepository::findCached()` — przyjmuje hash z `|`
- Inne metody ProductRepository / inne kontrolery / inne partiale
- Plików `templates_user/` (nie istnieje w tym repo, akcja po stronie klientów)
- Schemat bazy danych
- `.htaccess` w roocie (redirecty 301 wykluczone z scope)
## SCOPE LIMITS
- Tylko 4 pliki silnika + 2 pliki testów (lista w `files_modified`)
- Brak automatycznej regeneracji `pp_routes` — udokumentowane w SUMMARY jako akcja deploy
- Brak czyszczenia cache `pp_routes:all` w fazie — akcja deploy
- Brak regeneracji `google-feed.xml` w fazie — akcja deploy
- Brak redirectów 301 ze starych URL-i
</boundaries>
<verification>
- [ ] `./test.ps1` — pełna suita zielona (≥836 testów)
- [ ] Brak `str_replace('|', '/', ...)` w ProductRepository (grep)
- [ ] `[0-9_-]+` w obu wzorcach Helpers (grep)
- [ ] `permutation_hash` wyciągnięte do zmiennej w LayoutEngine z konwersją `_`→`|`
- [ ] Partial używa `$is_active` (forced_value_id || is_default) zamiast surowego `is_default`
- [ ] Wszystkie 5 AC spełnione
</verification>
<success_criteria>
- 4 pliki silnika zmienione zgodnie z instrukcją
- 2 nowe / zaktualizowane testy: routing regex + generator XML linku
- Pełna suita testów zielona
- Brak regresji w istniejących testach (834 → ≥836)
- SUMMARY zawiera dokładną listę akcji post-deploy (regen pp_routes, clear cache, regen feedu, resubmit GMC)
</success_criteria>
<output>
After completion, create `.paul/phases/18-google-feed-permutation-url-fix/18-01-SUMMARY.md` containing:
- Co zmienione (lista plików + diff highlights)
- Akcje post-deploy do wykonania ręcznie na produkcji (kolejność: regen pp_routes → clear cache pp_routes:all → regen google-feed.xml → resubmit GMC)
- Test count delta
- Decyzje (override /feature-dev, brak redirectów 301)
</output>

View File

@@ -0,0 +1,168 @@
---
phase: 18-google-feed-permutation-url-fix
plan: 01
subsystem: feed/routing
tags: [google-merchant, pp_routes, permutation, regex, php74]
requires:
- phase: prior-architecture
provides: ProductRepository, Helpers::htacces, LayoutEngine, frontAttributePartial
provides:
- Separator URL permutacji `_` zamiast `/` w feedzie Google
- Wzorzec routingu pp_routes obejmujący `[0-9_-]+`
- Konwersja `_``|` po stronie front przed `findCached`
- Preselekcja wartości atrybutu z `permutation_hash` w partialu
affects: [google-feed, pp_routes, frontend-product-attributes]
tech-stack:
added: []
patterns:
- "URL feedu: jeden segment z `_` zamiast wielu segmentów ze `/`"
- "DB format `|`, URL format `_`, konwersja w warstwie front"
key-files:
created:
- tests/Unit/Shared/Helpers/HelpersRoutingTest.php
- tests/Unit/Domain/Product/ProductFeedLinkTest.php
modified:
- autoload/Domain/Product/ProductRepository.php
- autoload/Shared/Helpers/Helpers.php
- autoload/front/LayoutEngine.php
- templates/shop-product/_partial/product-attribute.php
key-decisions:
- "Separator URL `_` zamiast `/` (one segment dopasowywalny przez pp_routes)"
- "Konwersja `_``|` w LayoutEngine, format DB pozostaje `|`"
- "Brak redirectów 301 — Google sam zaktualizuje feed"
- "Brak automatycznych akcji post-deploy — udokumentowane jako manual steps"
- "Override /feature-dev (hotfix z konkretną instrukcją)"
patterns-established:
- "Forced value via URL parameters w partialach (preselekcja zamiast is_default)"
- "Reflection-based test prywatnych metod XML feedu"
duration: ~25min
completed: 2026-04-30
---
# Phase 18 Plan 01: Google feed permutation URL fix — Summary
**Linki produktów z permutacją w feedzie Google używają teraz `_` jako separatora par `attr-val`, routing `pp_routes` matchuje takie URL-e, a partial atrybutu preselectuje wartości na podstawie `permutation_hash` z URL.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~25 min |
| Started | 2026-04-30 |
| Completed | 2026-04-30 |
| Tasks | 3 / 3 |
| Files modified | 6 (4 silnik + 2 testy) |
| Tests | 834 → 841 (+7) |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Generator linku w feedzie używa `_` | Pass | ProductFeedLinkTest.testCombinationLinkUsesUnderscoreInSeoLinkBranch + fallback + single-pair |
| AC-2: Routing `pp_routes` matchuje URL z `_` | Pass | HelpersRoutingTest weryfikuje obecność `[0-9_-]+` w generatorze + preg_match na nowym wzorcu |
| AC-3: Front konwertuje `_` z URL na `|` przed zapytaniem | Pass | LayoutEngine.php:196 — zmienna `$permutation_hash` z `str_replace('_','|',...)` |
| AC-4: Partial preselectuje wartość z URL | Pass | `$forced_value_id` + `$is_active` używane w `checked` i `<script>` |
| AC-5: Pełna suita testów zielona | Pass | PHPUnit: 841 tests, 2330 assertions, 0.764s |
## Accomplishments
- Naprawa krytycznego problemu komercyjnego: feed Google prowadził klientów na home zamiast na produkt
- Spójność stosu: separator URL (`_`) ↔ format DB (`|`) z jasnym punktem konwersji w warstwie front
- 7 nowych testów (4 routing + 3 generator linku) — pełne pokrycie zmiany
- Reflection-based test metody prywatnej `appendCombinationToXml` z mockiem Medoo i mockiem TransportRepository
- UI strony produktu wchodząc z linka feedu pokazuje wybraną kombinację atrybutów (zamiast `is_default`)
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `autoload/Domain/Product/ProductRepository.php` | Modified (×2) | `appendCombinationToXml`: separator `/``_` w obu gałęziach (seo_link i fallback) |
| `autoload/Shared/Helpers/Helpers.php` | Modified (×2) | Generator pp_routes: regex `[0-9-]+``[0-9_-]+` w obu wariantach |
| `autoload/front/LayoutEngine.php` | Modified | Wyciągnięcie `$permutation_hash` z konwersją `_``|` przed `findCached` |
| `templates/shop-product/_partial/product-attribute.php` | Modified | `$forced_value_id` z URL + `$is_active` w `checked`/`<script>` |
| `tests/Unit/Shared/Helpers/HelpersRoutingTest.php` | Created | 4 testy regex routingu (file content + preg_match dla `_` i odrzucenia `/`) |
| `tests/Unit/Domain/Product/ProductFeedLinkTest.php` | Created | 3 testy `appendCombinationToXml` via Reflection (seo_link, fallback, single-pair) |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Separator URL `_` zamiast `/` | `_` mieści się w jednym segmencie regex `[0-9_-]+`; `/` wymagałby zmiany struktury routingu | Czysty fix, minimalna zmiana w pp_routes |
| Format DB pozostaje `|` | Nie tykać zapisanych danych w `pp_shop_product_combinations.permutation_hash` | Zero migracji DB; konwersja tylko w warstwie I/O |
| Brak redirectów 301 | Stare URL-e z feedu wymarły gdy GMC zaciągnie nowy feed | Mniej kodu w `.htaccess`, brak długoterminowego balastu |
| Brak automatycznych akcji post-deploy | Hotfix dotyczy tylko silnika; regen routes/cache/feed są zależne od środowiska | Wymaga manualnego runbook'a (poniżej) |
| Override /feature-dev | Hotfix z konkretną instrukcją od użytkownika, jak fazy 15/16/17 | Skill audit logged |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Auto-fixed | 0 | — |
| Scope additions | 0 | — |
| Deferred | 0 | — |
**Total impact:** Zero. Plan wykonany dokładnie według instrukcji.
### Auto-fixed Issues
None.
### Deferred Items
None.
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| `ProductRepository::appendCombinationToXml` jest `private` z zależnościami od `transportRepoForXml` i `AttributeRepository` (DB) | Test via `ReflectionMethod`; mock Medoo (`select``[]`, `get``null`); wstrzyknięty mock `TransportRepository::lowestTransportPrice` zwracający `0.0` na dynamicznej property `transportRepoForXml` |
| Brak istniejącego folderu `tests/Unit/Shared/Helpers/` | Utworzony nowy katalog + `HelpersRoutingTest.php` |
| `Helpers::htacces()` zbyt rozległe do testu E2E (DB writes, file I/O) | Test pośredni: assercje na zawartości pliku Helpers.php (file_get_contents) + standalone `preg_match` na sample patternie |
## Post-deploy runbook (manual, kolejność krytyczna)
Wymagane akcje na środowisku produkcyjnym po deployu kodu:
1. **Regeneracja `pp_routes`** — wywołać `Helpers::htacces()` (np. zapis ustawień w adminie lub regeneracja sitemap), żeby nowe wzorce z `_` trafiły do bazy. Bez tego stare wzorce `[0-9-]+` w `pp_routes` nadal nie zmatchują URL z `_`.
2. **Wyczyścić cache routingu** — skasować klucz `pp_routes:all` w Redis (`DEL pp_routes:all`) albo poczekać 24h na expiry. `index.php:63` cachuje routing.
3. **Regeneracja feedu Google** — uruchomić cron `cron/cron-xml.php` (`\admin\factory\ShopProduct::generate_google_feed_xml()`), żeby `google-feed.xml` zawierał nowe linki z `_`.
4. **Resubmit feedu w GMC** — automatycznie wg harmonogramu lub ręcznie "Fetch now".
5. **Stare URL-e w GMC** — same wypadną z indeksu po podmianie feedu (Google).
Walidacja po deployu:
- `https://domena/google-feed.xml` — tagi `<link>` zawierają `_` zamiast `/` między parami
- `https://domena/slug-produktu/20-170_21-175` — ładuje produkt z preselectowaną kombinacją (nie home)
- GMC: feed bez błędów "Landing page error"
## Skill Audit (Phase 18)
| Expected | Invoked | Notes |
|----------|---------|-------|
| /feature-dev | ○ | User-approved override (hotfix z konkretną instrukcją) |
| /koniec-pracy | ○ | Pending — uruchomić przy zakończeniu sesji jeśli release wchodzi do update package |
## Next Phase Readiness
**Ready:**
- Hotfix completed; pełna suita zielona
- Brak zmian w schemacie DB
- Wzorzec preselekcji partial z URL parameter dostępny dla innych partiali (jeśli pojawi się potrzeba)
**Concerns:**
- Akcje post-deploy (regen routes / clear cache / regen feed) wymagają manualnego wykonania — brak automatu
- Klienci sklepu mający własne nadpisane `templates_user/shop-product/_partial/product-attribute.php` muszą zaaplikować zmianę u siebie (Tpl::view priorytetuje `templates_user/`)
**Blockers:**
- None.
---
*Phase: 18-google-feed-permutation-url-fix, Plan: 01*
*Completed: 2026-04-30*

File diff suppressed because one or more lines are too long

0
.scannerwork/.sonar_lock Normal file
View File

View File

@@ -0,0 +1,6 @@
projectKey=shopPRO
serverUrl=https://sonar.project-pro.pl
serverVersion=26.3.0.120487
dashboardUrl=https://sonar.project-pro.pl/dashboard?id=shopPRO
ceTaskId=33e10a49-4790-400e-bf25-9b40eaed053e
ceTaskUrl=https://sonar.project-pro.pl/api/ce/task?id=33e10a49-4790-400e-bf25-9b40eaed053e

View File

@@ -132,3 +132,17 @@ read_only_memory_patterns: []
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}

165
AGENTS.md
View File

@@ -1,4 +1,4 @@
# CLAUDE.md
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@@ -7,19 +7,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
shopPRO is a PHP e-commerce platform with an admin panel and customer-facing storefront. It uses Medoo ORM (`$mdb`), Redis caching, and a Domain-Driven Design architecture with Dependency Injection (migration from legacy architecture complete).
## Zasady pisania kodu
- Kod ma być czytelny dla obcego: jasne nazwy, mało magii
- Brak „skrótów na szybko typu logika w widokach, copy-paste, losowe helpery bez spójności
- Każda funkcja/klasa ma mieć jedną odpowiedzialność, zwykle do 3050 linii (jeśli dłuższe dzielić)
- max 3 poziomy zagnieżdżeń (if/foreach), reszta do osobnych metod
- Kod ma być czytelny „dla obcego”: jasne nazwy, mało magii
- Brak „skrótów na szybko” typu logika w widokach, copy-paste, losowe helpery bez spójności
- KaĹĽda funkcja/klasa ma mieć jednÄ… odpowiedzialność, zwykle do 30–50 linii (jeĹ›li dĹuĹĽsze – dzielić)
- max 3 poziomy zagnieżdżeń (if/foreach), reszta do osobnych metod
- Nazewnictwo:
- klasy: PascalCase
- metody/zmienne: camelCase
- stałe: UPPER_SNAKE_CASE
- Zero skrótologii w nazwach (np. $d, $tmp, $x1) poza pętlami 23 linijki
- medoo + prepared statements bez wyjątków (żadnego sklejania SQL stringiem)
- stałe: UPPER_SNAKE_CASE
- Zero „skrótologii” w nazwach (np. $d, $tmp, $x1) poza pętlami 2–3 linijki
- medoo + prepared statements bez wyjÄ…tkĂłw (ĹĽadnego sklejania SQL stringiem)
- XSS: escape w widokach (np. helper e())
- CSRF dla formularzy, sensowna obsługa sesji
- Kod ma mieć komentarze tylko tam, gdzie wyjaśniają „dlaczego, nie „co”
- CSRF dla formularzy, sensowna obsługa sesji
- Kod ma mieć komentarze tylko tam, gdzie wyjaśniają „dlaczego”, nie „co”
## PHP Version Constraint
@@ -36,7 +36,7 @@ shopPRO is a PHP e-commerce platform with an admin panel and customer-facing sto
### Running Tests
```bash
# Full suite (recommended PowerShell, auto-finds php)
# Full suite (recommended — PowerShell, auto-finds php)
./test.ps1
# Specific file
@@ -61,50 +61,50 @@ See `docs/UPDATE_INSTRUCTIONS.md` for the full procedure. Updates are ZIP packag
### Directory Structure
```
shopPRO/
├── autoload/ # Autoloaded classes (core codebase)
│ ├── Domain/ # Business logic repositories (\Domain\)
│ ├── Shared/ # Shared utilities (\Shared\)
│ │ ├── Cache/ # CacheHandler, RedisConnection
│ │ ├── Email/ # Email (PHPMailer wrapper)
│ │ ├── Helpers/ # Helpers (formerly class.S.php)
│ │ ├── Html/ # Html utility
│ │ ├── Image/ # ImageManipulator
│ │ └── Tpl/ # Template engine
│ ├── api/ # REST API layer (\api\)
│ │ ├── ApiRouter.php # API router (\api\ApiRouter)
│ │ └── Controllers/ # API controllers (\api\Controllers\)
│ ├── admin/ # Admin panel layer
│ │ ├── App.php # Admin router (\admin\App)
│ │ ├── Controllers/ # DI controllers (\admin\Controllers\) 28 controllers
│ │ ├── Support/ # TableListRequestFactory, Forms/FormRequestHandler, Forms/FormFieldRenderer
│ │ ├── Validation/ # FormValidator
│ │ └── ViewModels/ # Forms/ (FormEditViewModel, FormField, FormTab, FormAction, FormFieldType), Common/ (PaginatedTableViewModel)
│ └── front/ # Frontend layer
├── App.php # Frontend router (\front\App)
├── LayoutEngine.php # Layout engine (\front\LayoutEngine)
├── Controllers/ # DI controllers (\front\Controllers\) 8 controllers
└── Views/ # Static views (\front\Views\) 11 view classes
├── admin/ # Admin panel
│ ├── templates/ # Admin view templates
│ └── layout/ # Admin CSS/JS/icons
├── templates/ # Frontend view templates
├── libraries/ # Third-party libraries (Medoo, RedBeanPHP, PHPMailer)
├── tests/ # PHPUnit tests
│ ├── bootstrap.php
│ ├── stubs/ # Test stubs (CacheHandler, Helpers, ShopProduct)
│ └── Unit/
├── Domain/ # Repository tests
├── admin/Controllers/ # Controller tests
└── api/ # API tests
├── updates/ # Update packages for clients
├── docs/ # Technical documentation
├── config.php # Database/Redis config (not in repo)
├── index.php # Frontend entry point
├── ajax.php # Frontend AJAX handler
├── admin/index.php # Admin entry point
├── admin/ajax.php # Admin AJAX handler
├── cron.php # CRON jobs (Apilo sync)
└── api.php # REST API (ordersPRO + Ekomi)
├── autoload/ # Autoloaded classes (core codebase)
│ ├── Domain/ # Business logic repositories (\Domain\)
│ ├── Shared/ # Shared utilities (\Shared\)
│ │ ├── Cache/ # CacheHandler, RedisConnection
│ │ ├── Email/ # Email (PHPMailer wrapper)
│ │ ├── Helpers/ # Helpers (formerly class.S.php)
│ │ ├── Html/ # Html utility
│ │ ├── Image/ # ImageManipulator
│ │ └── Tpl/ # Template engine
│ ├── api/ # REST API layer (\api\)
│ │ ├── ApiRouter.php # API router (\api\ApiRouter)
│ │ └── Controllers/ # API controllers (\api\Controllers\)
│ ├── admin/ # Admin panel layer
│ │ ├── App.php # Admin router (\admin\App)
│ │ ├── Controllers/ # DI controllers (\admin\Controllers\) — 28 controllers
│ │ ├── Support/ # TableListRequestFactory, Forms/FormRequestHandler, Forms/FormFieldRenderer
│ │ ├── Validation/ # FormValidator
│ │ └── ViewModels/ # Forms/ (FormEditViewModel, FormField, FormTab, FormAction, FormFieldType), Common/ (PaginatedTableViewModel)
│ └── front/ # Frontend layer
│ ├── App.php # Frontend router (\front\App)
│ ├── LayoutEngine.php # Layout engine (\front\LayoutEngine)
│ ├── Controllers/ # DI controllers (\front\Controllers\) — 8 controllers
│ └── Views/ # Static views (\front\Views\) — 11 view classes
├── admin/ # Admin panel
│ ├── templates/ # Admin view templates
│ └── layout/ # Admin CSS/JS/icons
├── templates/ # Frontend view templates
├── libraries/ # Third-party libraries (Medoo, RedBeanPHP, PHPMailer)
├── tests/ # PHPUnit tests
│ ├── bootstrap.php
│ ├── stubs/ # Test stubs (CacheHandler, Helpers, ShopProduct)
│ └── Unit/
│ ├── Domain/ # Repository tests
│ ├── admin/Controllers/ # Controller tests
│ └── api/ # API tests
├── updates/ # Update packages for clients
├── docs/ # Technical documentation
├── config.php # Database/Redis config (not in repo)
├── index.php # Frontend entry point
├── ajax.php # Frontend AJAX handler
├── admin/index.php # Admin entry point
├── admin/ajax.php # Admin AJAX handler
├── cron.php # CRON jobs (Apilo sync)
└── api.php # REST API (ordersPRO + Ekomi)
```
### Autoloader
@@ -114,19 +114,19 @@ Custom autoloader in each entry point (not Composer autoload at runtime). Tries
2. `autoload/{namespace}/{ClassName}.php` (PSR-4 style, fallback)
### Namespace Conventions (case-sensitive on Linux!)
- `\Domain\` `autoload/Domain/` (uppercase D)
- `\admin\Controllers\` `autoload/admin/Controllers/` (lowercase a)
- `\Shared\` `autoload/Shared/`
- `\api\` `autoload/api/`
- Do NOT use `\Admin\` (uppercase A) the server directory is `admin/` (lowercase)
- `\shop\` namespace is **deleted** all 12 legacy classes migrated to `\Domain\`, `autoload/shop/` directory removed
- `\Domain\` → `autoload/Domain/` (uppercase D)
- `\admin\Controllers\` → `autoload/admin/Controllers/` (lowercase a)
- `\Shared\` → `autoload/Shared/`
- `\api\` → `autoload/api/`
- Do NOT use `\Admin\` (uppercase A) — the server directory is `admin/` (lowercase)
- `\shop\` namespace is **deleted** — all 12 legacy classes migrated to `\Domain\`, `autoload/shop/` directory removed
### Domain-Driven Architecture (migration complete)
All legacy directories (`admin/controls/`, `admin/factory/`, `admin/view/`, `front/controls/`, `front/view/`, `front/factory/`, `shop/`) have been deleted. All modules now use this pattern:
**Domain Layer** (`autoload/Domain/{Module}/`):
- `{Module}Repository.php` data access, business logic, Redis caching
- `{Module}Repository.php` — data access, business logic, Redis caching
- Constructor DI with `$db` (Medoo instance)
- Methods serve both admin and frontend (shared Domain, no separate services)
@@ -141,7 +141,7 @@ All legacy directories (`admin/controls/`, `admin/factory/`, `admin/view/`, `fro
- Wired in `front\App::getControllerFactories()`
**Frontend Views** (`autoload/front/Views/`):
- Static classes, no state, no DI pure rendering
- Static classes, no state, no DI — pure rendering
**API Controllers** (`autoload/api/Controllers/`):
- DI via constructor, stateless (no session)
@@ -151,13 +151,13 @@ All legacy directories (`admin/controls/`, `admin/factory/`, `admin/view/`, `fro
### Key Classes
| Class | Purpose |
|-------|---------|
| `\admin\App` | Admin router maps URL segments to controllers |
| `\front\App` | Frontend router `route()`, `checkUrlParams()` |
| `\front\LayoutEngine` | Frontend layout engine `show()`, tag replacement |
| `\admin\App` | Admin router — maps URL segments to controllers |
| `\front\App` | Frontend router — `route()`, `checkUrlParams()` |
| `\front\LayoutEngine` | Frontend layout engine — `show()`, tag replacement |
| `\Shared\Helpers\Helpers` | Utility methods (SEO, email, cache clearing) |
| `\Shared\Tpl\Tpl` | Template engine `render()`, `set()` |
| `\Shared\Cache\CacheHandler` | Redis cache `get()`, `set()`, `delete()`, `deletePattern()` |
| `\api\ApiRouter` | REST API router auth, routing, response helpers |
| `\Shared\Tpl\Tpl` | Template engine — `render()`, `set()` |
| `\Shared\Cache\CacheHandler` | Redis cache — `get()`, `set()`, `delete()`, `deletePattern()` |
| `\api\ApiRouter` | REST API router — auth, routing, response helpers |
### Database
- ORM: Medoo (`$mdb` global variable, injected via DI in new code)
@@ -179,7 +179,7 @@ Universal form system for admin edit views. Docs: `docs/FORM_EDIT_SYSTEM.md`.
- Clear product cache: `\Shared\Helpers\Helpers::clear_product_cache($id)`
- Pattern delete: `CacheHandler::deletePattern("shop\\product:{$id}:*")`
- Default TTL: 86400 (24h)
- Data is serialized requires `unserialize()` after `get()`
- Data is serialized — requires `unserialize()` after `get()`
- Config: `config.php` (`$config['redis']`)
## Code Patterns
@@ -203,7 +203,7 @@ $controller = new \admin\Controllers\ExampleController($repo);
```
### Medoo ORM pitfalls
- `$mdb->delete($table, $where)` takes **2 arguments**, NOT 3 has caused bugs
- `$mdb->delete($table, $where)` takes **2 arguments**, NOT 3 — has caused bugs
- `$mdb->get()` returns `null` when no record, NOT `false`
- After `$mdb->insert()`, check `$mdb->id()` to confirm success
@@ -222,18 +222,19 @@ $controller = new \admin\Controllers\ExampleController($repo);
When user says **"KONIEC PRACY"**, run `/koniec-pracy` (see `.claude/commands/koniec-pracy.md`).
Before starting implementation, review current state of docs.
For documentation updates, modify only .paul/docs/* unless the user explicitly asks to update root docs/*.
## Key Documentation
- `docs/MEMORY.md` project memory: known issues, confirmed patterns, ORM pitfalls, caching conventions
- `docs/PROJECT_STRUCTURE.md` current architecture, layers, cache, entry points, integrations
- `docs/DATABASE_STRUCTURE.md` full database schema
- `docs/TESTING.md` test suite guide and structure
- `docs/FORM_EDIT_SYSTEM.md` form system architecture
- `docs/CHANGELOG.md` version history
- `api-docs/api-reference.json` REST API documentation (ordersPRO)
- `api-docs/index.html` REST API documentation (ordersPRO)
- `docs/UPDATE_INSTRUCTIONS.md` how to build client update packages
- `docs/MEMORY.md` — project memory: known issues, confirmed patterns, ORM pitfalls, caching conventions
- `docs/PROJECT_STRUCTURE.md` — current architecture, layers, cache, entry points, integrations
- `docs/DATABASE_STRUCTURE.md` — full database schema
- `docs/TESTING.md` — test suite guide and structure
- `docs/FORM_EDIT_SYSTEM.md` — form system architecture
- `docs/CHANGELOG.md` — version history
- `api-docs/api-reference.json` — REST API documentation (ordersPRO)
- `api-docs/index.html` — REST API documentation (ordersPRO)
- `docs/UPDATE_INSTRUCTIONS.md` — how to build client update packages
## Za każdym razem jak próbujesz sprawdzić jakiś plik z logami spróbuj go najpierw pobrać z serwera FTP
## Za każdym razem jak próbujesz sprawdzić jakiś plik z logami spróbuj go najpierw pobrać z serwera FTP
## Wszystkie pliki które tworzysz jako pomocnicze, np build_0330.ps1 czy build-update.ps1 twórz w folderze temp
## Wszystkie pliki ktĂłre tworzysz jako pomocnicze, np build_0330.ps1 czy build-update.ps1 twĂłrz w folderze temp

176
CLAUDE.md
View File

@@ -1,4 +1,4 @@
# CLAUDE.md
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@@ -7,19 +7,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
shopPRO is a PHP e-commerce platform with an admin panel and customer-facing storefront. It uses Medoo ORM (`$mdb`), Redis caching, and a Domain-Driven Design architecture with Dependency Injection (migration from legacy architecture complete).
## Zasady pisania kodu
- Kod ma być czytelny dla obcego: jasne nazwy, mało magii
- Brak „skrótów na szybko typu logika w widokach, copy-paste, losowe helpery bez spójności
- Każda funkcja/klasa ma mieć jedną odpowiedzialność, zwykle do 3050 linii (jeśli dłuższe dzielić)
- max 3 poziomy zagnieżdżeń (if/foreach), reszta do osobnych metod
- Kod ma być czytelny „dla obcego”: jasne nazwy, mało magii
- Brak „skrótów na szybko” typu logika w widokach, copy-paste, losowe helpery bez spójności
- KaĹĽda funkcja/klasa ma mieć jednÄ… odpowiedzialność, zwykle do 30–50 linii (jeĹ›li dĹuĹĽsze – dzielić)
- max 3 poziomy zagnieżdżeń (if/foreach), reszta do osobnych metod
- Nazewnictwo:
- klasy: PascalCase
- metody/zmienne: camelCase
- stałe: UPPER_SNAKE_CASE
- Zero skrótologii w nazwach (np. $d, $tmp, $x1) poza pętlami 23 linijki
- medoo + prepared statements bez wyjątków (żadnego sklejania SQL stringiem)
- stałe: UPPER_SNAKE_CASE
- Zero „skrótologii” w nazwach (np. $d, $tmp, $x1) poza pętlami 2–3 linijki
- medoo + prepared statements bez wyjÄ…tkĂłw (ĹĽadnego sklejania SQL stringiem)
- XSS: escape w widokach (np. helper e())
- CSRF dla formularzy, sensowna obsługa sesji
- Kod ma mieć komentarze tylko tam, gdzie wyjaśniają „dlaczego, nie „co”
- CSRF dla formularzy, sensowna obsługa sesji
- Kod ma mieć komentarze tylko tam, gdzie wyjaśniają „dlaczego”, nie „co”
## PHP Version Constraint
@@ -36,7 +36,7 @@ shopPRO is a PHP e-commerce platform with an admin panel and customer-facing sto
### Running Tests
```bash
# Full suite (recommended PowerShell, auto-finds php)
# Full suite (recommended — PowerShell, auto-finds php)
./test.ps1
# Specific file
@@ -55,60 +55,60 @@ composer test # standard
PHPUnit 9.6 via `phpunit.phar`. Bootstrap: `tests/bootstrap.php`. Config: `phpunit.xml`.
Current suite: **818 tests, 2275 assertions**.
Current suite: **823 tests, 2284 assertions**.
### Creating Updates
See `docs/UPDATE_INSTRUCTIONS.md` for the full procedure. Updates are ZIP packages in `updates/0.XX/`. Never include `*.md` files, `updates/changelog.php`, or root `.htaccess` in update ZIPs. ZIP structure must start directly from project directories no version subfolder inside the archive.
See `docs/UPDATE_INSTRUCTIONS.md` for the full procedure. Updates are ZIP packages in `updates/0.XX/`. Never include `*.md` files, `updates/changelog.php`, or root `.htaccess` in update ZIPs. ZIP structure must start directly from project directories — no version subfolder inside the archive.
## Architecture
### Directory Structure
```
shopPRO/
├── autoload/ # Autoloaded classes (core codebase)
│ ├── Domain/ # Business logic repositories (\Domain\)
│ ├── Shared/ # Shared utilities (\Shared\)
│ │ ├── Cache/ # CacheHandler, RedisConnection
│ │ ├── Email/ # Email (PHPMailer wrapper)
│ │ ├── Helpers/ # Helpers (formerly class.S.php)
│ │ ├── Html/ # Html utility
│ │ ├── Image/ # ImageManipulator
│ │ └── Tpl/ # Template engine
│ ├── api/ # REST API layer (\api\)
│ │ ├── ApiRouter.php # API router (\api\ApiRouter)
│ │ └── Controllers/ # API controllers (\api\Controllers\)
│ ├── admin/ # Admin panel layer
│ │ ├── App.php # Admin router (\admin\App)
│ │ ├── Controllers/ # DI controllers (\admin\Controllers\) 28 controllers
│ │ ├── Support/ # TableListRequestFactory, Forms/FormRequestHandler, Forms/FormFieldRenderer
│ │ ├── Validation/ # FormValidator
│ │ └── ViewModels/ # Forms/ (FormEditViewModel, FormField, FormTab, FormAction, FormFieldType), Common/ (PaginatedTableViewModel)
│ └── front/ # Frontend layer
├── App.php # Frontend router (\front\App)
├── LayoutEngine.php # Layout engine (\front\LayoutEngine)
├── Controllers/ # DI controllers (\front\Controllers\) 8 controllers
└── Views/ # Static views (\front\Views\) 11 view classes
├── admin/ # Admin panel
│ ├── templates/ # Admin view templates
│ └── layout/ # Admin CSS/JS/icons
├── templates/ # Frontend view templates
├── libraries/ # Third-party libraries (Medoo, RedBeanPHP, PHPMailer)
├── tests/ # PHPUnit tests
│ ├── bootstrap.php
│ ├── stubs/ # Test stubs (CacheHandler, Helpers, ShopProduct)
│ └── Unit/
├── Domain/ # Repository tests
├── admin/Controllers/ # Controller tests
└── api/ # API tests
├── updates/ # Update packages for clients
├── docs/ # Technical documentation
├── config.php # Database/Redis config (not in repo)
├── index.php # Frontend entry point
├── ajax.php # Frontend AJAX handler
├── admin/index.php # Admin entry point
├── admin/ajax.php # Admin AJAX handler
├── cron.php # CRON jobs (Apilo sync)
└── api.php # REST API (ordersPRO + Ekomi)
├── autoload/ # Autoloaded classes (core codebase)
│ ├── Domain/ # Business logic repositories (\Domain\)
│ ├── Shared/ # Shared utilities (\Shared\)
│ │ ├── Cache/ # CacheHandler, RedisConnection
│ │ ├── Email/ # Email (PHPMailer wrapper)
│ │ ├── Helpers/ # Helpers (formerly class.S.php)
│ │ ├── Html/ # Html utility
│ │ ├── Image/ # ImageManipulator
│ │ └── Tpl/ # Template engine
│ ├── api/ # REST API layer (\api\)
│ │ ├── ApiRouter.php # API router (\api\ApiRouter)
│ │ └── Controllers/ # API controllers (\api\Controllers\)
│ ├── admin/ # Admin panel layer
│ │ ├── App.php # Admin router (\admin\App)
│ │ ├── Controllers/ # DI controllers (\admin\Controllers\) — 28 controllers
│ │ ├── Support/ # TableListRequestFactory, Forms/FormRequestHandler, Forms/FormFieldRenderer
│ │ ├── Validation/ # FormValidator
│ │ └── ViewModels/ # Forms/ (FormEditViewModel, FormField, FormTab, FormAction, FormFieldType), Common/ (PaginatedTableViewModel)
│ └── front/ # Frontend layer
│ ├── App.php # Frontend router (\front\App)
│ ├── LayoutEngine.php # Layout engine (\front\LayoutEngine)
│ ├── Controllers/ # DI controllers (\front\Controllers\) — 8 controllers
│ └── Views/ # Static views (\front\Views\) — 11 view classes
├── admin/ # Admin panel
│ ├── templates/ # Admin view templates
│ └── layout/ # Admin CSS/JS/icons
├── templates/ # Frontend view templates
├── libraries/ # Third-party libraries (Medoo, RedBeanPHP, PHPMailer)
├── tests/ # PHPUnit tests
│ ├── bootstrap.php
│ ├── stubs/ # Test stubs (CacheHandler, Helpers, ShopProduct)
│ └── Unit/
│ ├── Domain/ # Repository tests
│ ├── admin/Controllers/ # Controller tests
│ └── api/ # API tests
├── updates/ # Update packages for clients
├── docs/ # Technical documentation
├── config.php # Database/Redis config (not in repo)
├── index.php # Frontend entry point
├── ajax.php # Frontend AJAX handler
├── admin/index.php # Admin entry point
├── admin/ajax.php # Admin AJAX handler
├── cron.php # CRON jobs (Apilo sync)
└── api.php # REST API (ordersPRO + Ekomi)
```
### Autoloader
@@ -118,19 +118,19 @@ Custom autoloader in each entry point (not Composer autoload at runtime). Tries
2. `autoload/{namespace}/{ClassName}.php` (PSR-4 style, fallback)
### Namespace Conventions (case-sensitive on Linux!)
- `\Domain\` `autoload/Domain/` (uppercase D)
- `\admin\Controllers\` `autoload/admin/Controllers/` (lowercase a)
- `\Shared\` `autoload/Shared/`
- `\api\` `autoload/api/`
- Do NOT use `\Admin\` (uppercase A) the server directory is `admin/` (lowercase)
- `\shop\` namespace is **deleted** all 12 legacy classes migrated to `\Domain\`, `autoload/shop/` directory removed
- `\Domain\` → `autoload/Domain/` (uppercase D)
- `\admin\Controllers\` → `autoload/admin/Controllers/` (lowercase a)
- `\Shared\` → `autoload/Shared/`
- `\api\` → `autoload/api/`
- Do NOT use `\Admin\` (uppercase A) — the server directory is `admin/` (lowercase)
- `\shop\` namespace is **deleted** — all 12 legacy classes migrated to `\Domain\`, `autoload/shop/` directory removed
### Domain-Driven Architecture (migration complete)
All legacy directories (`admin/controls/`, `admin/factory/`, `admin/view/`, `front/controls/`, `front/view/`, `front/factory/`, `shop/`) have been deleted. All modules now use this pattern:
**Domain Layer** (`autoload/Domain/{Module}/`):
- `{Module}Repository.php` data access, business logic, Redis caching
- `{Module}Repository.php` — data access, business logic, Redis caching
- Constructor DI with `$db` (Medoo instance)
- Methods serve both admin and frontend (shared Domain, no separate services)
@@ -145,7 +145,7 @@ All legacy directories (`admin/controls/`, `admin/factory/`, `admin/view/`, `fro
- Wired in `front\App::getControllerFactories()`
**Frontend Views** (`autoload/front/Views/`):
- Static classes, no state, no DI pure rendering
- Static classes, no state, no DI — pure rendering
**API Controllers** (`autoload/api/Controllers/`):
- DI via constructor, stateless (no session)
@@ -155,13 +155,13 @@ All legacy directories (`admin/controls/`, `admin/factory/`, `admin/view/`, `fro
### Key Classes
| Class | Purpose |
|-------|---------|
| `\admin\App` | Admin router maps URL segments to controllers |
| `\front\App` | Frontend router `route()`, `checkUrlParams()` |
| `\front\LayoutEngine` | Frontend layout engine `show()`, tag replacement |
| `\admin\App` | Admin router — maps URL segments to controllers |
| `\front\App` | Frontend router — `route()`, `checkUrlParams()` |
| `\front\LayoutEngine` | Frontend layout engine — `show()`, tag replacement |
| `\Shared\Helpers\Helpers` | Utility methods (SEO, email, cache clearing) |
| `\Shared\Tpl\Tpl` | Template engine `render()`, `set()` |
| `\Shared\Cache\CacheHandler` | Redis cache `get()`, `set()`, `delete()`, `deletePattern()` |
| `\api\ApiRouter` | REST API router auth, routing, response helpers |
| `\Shared\Tpl\Tpl` | Template engine — `render()`, `set()` |
| `\Shared\Cache\CacheHandler` | Redis cache — `get()`, `set()`, `delete()`, `deletePattern()` |
| `\api\ApiRouter` | REST API router — auth, routing, response helpers |
### Database
- ORM: Medoo (`$mdb` global variable, injected via DI in new code)
@@ -183,7 +183,7 @@ Universal form system for admin edit views. Docs: `docs/FORM_EDIT_SYSTEM.md`.
- Clear product cache: `\Shared\Helpers\Helpers::clear_product_cache($id)`
- Pattern delete: `CacheHandler::deletePattern("shop\\product:{$id}:*")`
- Default TTL: 86400 (24h)
- Data is serialized requires `unserialize()` after `get()`
- Data is serialized — requires `unserialize()` after `get()`
- Config: `config.php` (`$config['redis']`)
## Code Patterns
@@ -207,7 +207,7 @@ $controller = new \admin\Controllers\ExampleController($repo);
```
### Medoo ORM pitfalls
- `$mdb->delete($table, $where)` takes **2 arguments**, NOT 3 has caused bugs
- `$mdb->delete($table, $where)` takes **2 arguments**, NOT 3 — has caused bugs
- `$mdb->get()` returns `null` when no record, NOT `false`
- After `$mdb->insert()`, check `$mdb->id()` to confirm success
@@ -226,21 +226,23 @@ $controller = new \admin\Controllers\ExampleController($repo);
When user says **"KONIEC PRACY"**, run `/koniec-pracy` (see `.claude/commands/koniec-pracy.md`).
Before starting implementation, review current state of docs.
For documentation updates, modify only .paul/docs/* unless the user explicitly asks to update root docs/*.
## Key Documentation
- `docs/MEMORY.md` project memory: known issues, confirmed patterns, ORM pitfalls, caching conventions
- `docs/PROJECT_STRUCTURE.md` current architecture, layers, cache, entry points, integrations
- `docs/DATABASE_STRUCTURE.md` full database schema
- `docs/TESTING.md` test suite guide and structure
- `docs/FORM_EDIT_SYSTEM.md` form system architecture
- `docs/CLASS_CATALOG.md` full catalog of all classes with descriptions
- `docs/TODO.md` outstanding tasks and planned features
- `docs/CRON_QUEUE_PLAN.md` planned cron/queue architecture
- `docs/CHANGELOG.md` version history
- `api-docs/api-reference.json` REST API documentation (ordersPRO)
- `api-docs/index.html` REST API documentation (ordersPRO)
- `docs/UPDATE_INSTRUCTIONS.md` how to build client update packages
- `docs/MEMORY.md` — project memory: known issues, confirmed patterns, ORM pitfalls, caching conventions
- `docs/PROJECT_STRUCTURE.md` — current architecture, layers, cache, entry points, integrations
- `docs/DATABASE_STRUCTURE.md` — full database schema
- `docs/TESTING.md` — test suite guide and structure
- `docs/FORM_EDIT_SYSTEM.md` — form system architecture
- `docs/CLASS_CATALOG.md` — full catalog of all classes with descriptions
- `docs/TODO.md` — outstanding tasks and planned features
- `docs/CRON_QUEUE_PLAN.md` — planned cron/queue architecture
- `docs/CHANGELOG.md` — version history
- `api-docs/api-reference.json` — REST API documentation (ordersPRO)
- `api-docs/index.html` — REST API documentation (ordersPRO)
- `docs/UPDATE_INSTRUCTIONS.md` — how to build client update packages
## Za każdym razem jak próbujesz sprawdzić jakiś plik z logami spróbuj go najpierw pobrać z serwera FTP
## Za każdym razem jak próbujesz sprawdzić jakiś plik z logami spróbuj go najpierw pobrać z serwera FTP
## Wszystkie pliki ktĂłre tworzysz jako pomocnicze, np build_0330.ps1 czy build-update.ps1 twĂłrz w folderze temp
## Wszystkie pliki które tworzysz jako pomocnicze, np build_0330.ps1 czy build-update.ps1 twórz w folderze temp

View File

@@ -1,3 +1,20 @@
<?php $customLabelsEnabled = !empty( $this->custom_labels_enabled ); ?>
<script type="text/javascript">
$(function() {
var $header = $( '.panel-heading .col-sm-8' );
if ( !$header.length ) {
return;
}
if ( $header.find( '.btn-toggle-custom-labels' ).length === 0 ) {
var buttonClass = <?= $customLabelsEnabled ? "'btn-danger'" : "'btn-success'" ?>;
var buttonText = <?= $customLabelsEnabled ? "'Ukryj etykiety niestandardowe'" : "'Pokaż etykiety niestandardowe'" ?>;
$header.append( ' <a href=\"#\" class=\"btn btn-sm btn-toggle-custom-labels ' + buttonClass + '\"><i class=\"fa fa-tags mr5\"></i>' + buttonText + '</a>' );
}
});
</script>
<? if ( $this -> shoppro_enabled ):?>
<script type="text/javascript">
$(function() {
@@ -27,10 +44,19 @@
.product-categories {
display: block;
}
.custom-label-suggestions {
max-height: 220px;
overflow-y: auto;
}
.custom-labels small {
display: block;
margin-bottom: 3px;
}
</style>
<script type="text/javascript">
$(function() {
var customLabelSuggestionTimers = {};
// --- Inline price save ---
$( 'body' ).on( 'change', '.product-price', function() {
@@ -54,6 +80,139 @@ $(function() {
});
});
// --- Toggle custom labels ---
$( 'body' ).on( 'click', '.btn-toggle-custom-labels', function(e) {
e.preventDefault();
$.ajax({
type: 'POST',
cache: false,
url: '/admin/shop_product/product_custom_labels_toggle/',
beforeSend: function() { $( '#overlay' ).show(); },
success: function( response ) {
$( '#overlay' ).hide();
var data = jQuery.parseJSON( response );
if ( data.status === 'ok' ) {
window.location.reload();
return;
}
create_error( 'Nie udało się przełączyć widoku etykiet.' );
},
error: function() {
$( '#overlay' ).hide();
create_error( 'Nie udało się przełączyć widoku etykiet.' );
}
});
});
// --- Custom label suggestions + save ---
function hideCustomLabelSuggestions( $input ) {
var $container = $input.closest( '[class*=\"custom_label_\"]' );
$container.find( '.custom-label-suggestions' ).hide().empty();
var datalistId = $input.attr( 'data-datalist-id' ) || '';
if ( datalistId ) {
$( '#' + datalistId ).empty();
}
}
function saveCustomLabelValue( $input ) {
$.ajax({
type: 'POST',
cache: false,
url: '/admin/shop_product/product_custom_label_save/',
data: {
product_id: $input.attr( 'data-product-id' ),
label_type: $input.attr( 'data-label-type' ),
custom_label: $input.val()
},
success: function( response ) {
var data = jQuery.parseJSON( response );
if ( data.status !== 'ok' ) {
create_error( data.msg || 'Nie udało się zapisać etykiety.' );
}
},
error: function() {
create_error( 'Nie udało się zapisać etykiety.' );
}
});
}
function loadCustomLabelSuggestions( $input ) {
var labelType = $input.attr( 'data-label-type' );
var query = $input.val();
var $suggestions = $input.closest( '[class*=\"custom_label_\"]' ).find( '.custom-label-suggestions' );
var datalistId = $input.attr( 'data-datalist-id' ) || '';
var $datalist = datalistId ? $( '#' + datalistId ) : $();
if ( !labelType ) {
return;
}
$.ajax({
type: 'POST',
cache: false,
url: '/admin/shop_product/product_custom_label_suggestions/',
data: {
label_type: labelType,
custom_label: query
},
success: function( response ) {
var data = jQuery.parseJSON( response );
if ( data.status !== 'ok' || !data.suggestions || !data.suggestions.length ) {
$suggestions.hide().empty();
$datalist.empty();
return;
}
var html = '';
$.each( data.suggestions, function( index, item ) {
if ( !item.label ) {
return;
}
var safe = String( item.label ).replace(/\"/g, '&quot;');
html += '<option value=\"' + safe + '\">' + item.label + '</option>';
});
if ( html ) {
$datalist.html( html );
$suggestions.hide().empty();
} else {
$suggestions.hide().empty();
$datalist.empty();
}
},
error: function() {
$suggestions.hide().empty();
$datalist.empty();
}
});
}
$( 'body' ).on( 'input', '.product-custom-label', function() {
var $input = $( this );
var inputKey = $input.attr( 'data-product-id' ) + ':' + $input.attr( 'data-label-type' );
if ( customLabelSuggestionTimers[inputKey] ) {
clearTimeout( customLabelSuggestionTimers[inputKey] );
}
customLabelSuggestionTimers[inputKey] = setTimeout( function() {
loadCustomLabelSuggestions( $input );
}, 250 );
});
$( 'body' ).on( 'change', '.product-custom-label', function() {
saveCustomLabelValue( $( this ) );
});
$( document ).on( 'click', function(e) {
if ( $( e.target ).closest( '.custom-labels' ).length === 0 ) {
$( '.custom-label-suggestions' ).hide().empty();
}
});
$( 'body' ).on( 'change', '.product-price-promo', function() {
var $el = $( this );
var price = $el.val().replace( ' ', '' );

View File

@@ -5,5 +5,6 @@
'list' => $this->viewModel,
'apilo_enabled' => $this->apilo_enabled,
'shoppro_enabled' => $this->shoppro_enabled,
'custom_labels_enabled' => $this->custom_labels_enabled,
]); ?>
<?php endif; ?>

View File

@@ -130,10 +130,22 @@ class CronJobRepository
*/
public function markFailed($jobId, $error, $attempt = 1)
{
$job = $this->db->get('pp_cron_jobs', ['max_attempts', 'attempts'], ['id' => $jobId]);
$job = $this->db->get('pp_cron_jobs', ['job_type', 'max_attempts', 'attempts'], ['id' => $jobId]);
$attempts = $job ? (int) $job['attempts'] : $attempt;
$maxAttempts = $job ? (int) $job['max_attempts'] : 10;
$jobType = $job ? $job['job_type'] : '';
// Order-related Apilo joby — infinite retry co 30 min
if (CronJobType::isOrderRelatedApiloJob($jobType)) {
$nextRun = date('Y-m-d H:i:s', time() + CronJobType::APILO_ORDER_BACKOFF_SECONDS);
$this->db->update('pp_cron_jobs', [
'status' => CronJobType::STATUS_PENDING,
'last_error' => mb_substr($error, 0, 500),
'scheduled_at' => $nextRun,
], ['id' => $jobId]);
return;
}
if ($attempts >= $maxAttempts) {
// Przekroczono limit prób — trwale failed

View File

@@ -34,6 +34,7 @@ class CronJobType
// Backoff
const BASE_BACKOFF_SECONDS = 60;
const MAX_BACKOFF_SECONDS = 3600;
const APILO_ORDER_BACKOFF_SECONDS = 1800; // 30 min — stały interwał dla order jobów
/**
* @return string[]
@@ -69,6 +70,19 @@ class CronJobType
];
}
/**
* @param string $jobType
* @return bool
*/
public static function isOrderRelatedApiloJob($jobType)
{
return in_array($jobType, [
self::APILO_SEND_ORDER,
self::APILO_SYNC_PAYMENT,
self::APILO_SYNC_STATUS,
], true);
}
/**
* @param int $attempt
* @return int

View File

@@ -1335,8 +1335,9 @@ class ProductRepository
$this->saveImagesOrder( $productId, $d['gallery_order'] );
}
// Zapisz custom fields tylko gdy jawnie podane (partial update przez API może nie zawierać tego klucza)
if ( array_key_exists( 'custom_field_name', $d ) ) {
// Zapisz custom fields tylko gdy formularz edycji renderował sekcję (marker hidden field)
// API partial update nie zawiera tego markera — custom fields pominięte
if ( array_key_exists( 'custom_field_name_present', $d ) ) {
$this->saveCustomFields( $productId, $d['custom_field_name'] ?? [], $d['custom_field_type'] ?? [], $d['custom_field_required'] ?? [] );
}
@@ -1751,8 +1752,10 @@ class ProductRepository
if ( \Shared\Helpers\Helpers::is_array_fix( $customFields ) ) {
foreach ( $customFields as $row ) {
$this->db->insert( 'pp_shop_products_custom_fields', [
'id_product' => $newProductId,
'name' => $row['name'],
'id_product' => $newProductId,
'name' => $row['name'],
'type' => $row['type'] ?? 'text',
'is_required' => $row['is_required'] ?? 0,
] );
}
}
@@ -2202,6 +2205,44 @@ class ProductRepository
] );
}
/**
* Pobiera nazwy etykiet custom_label_0..4 z bazy ustawien.
*
* @return array<string, string>
*/
public function customLabelNames(): array
{
$names = [];
for ( $index = 0; $index < 5; $index++ ) {
$fieldName = 'custom_label_' . $index;
$names[$fieldName] = 'Custom label ' . $index;
}
$settingsKeys = [];
for ( $index = 0; $index < 5; $index++ ) {
$settingsKeys[] = 'custom_label_' . $index . '_name';
$settingsKeys[] = 'google_custom_label_' . $index . '_name';
}
$settingsRows = $this->db->select( 'pp_settings', [ 'param', 'value' ], [ 'param' => $settingsKeys ] );
if ( is_array( $settingsRows ) ) {
foreach ( $settingsRows as $settingRow ) {
$param = (string) ( $settingRow['param'] ?? '' );
$value = trim( (string) ( $settingRow['value'] ?? '' ) );
if ( $value === '' ) {
continue;
}
if ( preg_match( '/^(?:google_)?custom_label_([0-4])_name$/', $param, $match ) ) {
$names[ 'custom_label_' . $match[1] ] = $value;
}
}
}
return $names;
}
/**
* Pobiera sugestie custom label.
*/
@@ -2328,9 +2369,9 @@ class ProductRepository
$itemNode->appendChild( $doc->createElement( 'g:description', html_entity_decode( strip_tags( $desc ) ) ) );
if ( $product['language']['seo_link'] ) {
$link = $domainPrefix . '://' . $url . '/' . \Shared\Helpers\Helpers::seo( $product['language']['seo_link'] ) . '/' . str_replace( '|', '/', $combination['permutation_hash'] );
$link = $domainPrefix . '://' . $url . '/' . \Shared\Helpers\Helpers::seo( $product['language']['seo_link'] ) . '/' . str_replace( '|', '_', $combination['permutation_hash'] );
} else {
$link = $domainPrefix . '://' . $url . '/p-' . $product['id'] . '-' . \Shared\Helpers\Helpers::seo( $product['language']['name'] ) . '/' . str_replace( '|', '/', $combination['permutation_hash'] );
$link = $domainPrefix . '://' . $url . '/p-' . $product['id'] . '-' . \Shared\Helpers\Helpers::seo( $product['language']['name'] ) . '/' . str_replace( '|', '_', $combination['permutation_hash'] );
}
$itemNode->appendChild( $doc->createElement( 'link', $link ) );

View File

@@ -691,12 +691,12 @@ class Helpers
if ( $row2['seo_link'] )
{
$mdb->insert( 'pp_routes', [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'], 'pattern' => '^' . $language_link . self::seo( $row2['seo_link'] ) . '$', 'destination' => 'index.php?product=' . $row2['product_id'] ] );
$mdb->insert( 'pp_routes', [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'], 'pattern' => '^' . $language_link . self::seo( $row2['seo_link'] ) . '/([0-9-]+)$', 'destination' => 'index.php?product=' . $row2['product_id'] . '&permutation_hash=$1' ] );
$mdb->insert( 'pp_routes', [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'], 'pattern' => '^' . $language_link . self::seo( $row2['seo_link'] ) . '/([0-9_-]+)$', 'destination' => 'index.php?product=' . $row2['product_id'] . '&permutation_hash=$1' ] );
}
else
{
$mdb->insert( 'pp_routes', [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'], 'pattern' => '^' . $language_link . 'p-' . $row2['product_id'] . '-' . self::seo( $row2['name'] ) . '$', 'destination' => 'index.php?product=' . $row2['product_id'] ] );
$mdb->insert( 'pp_routes', [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'], 'pattern' => '^' . $language_link . 'p-' . $row2['product_id'] . '-' . self::seo( $row2['name'] ) . '/([0-9-]+)$', 'destination' => 'index.php?product=' . $row2['product_id'] . '&permutation_hash=$1' ] );
$mdb->insert( 'pp_routes', [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'], 'pattern' => '^' . $language_link . 'p-' . $row2['product_id'] . '-' . self::seo( $row2['name'] ) . '/([0-9_-]+)$', 'destination' => 'index.php?product=' . $row2['product_id'] . '&permutation_hash=$1' ] );
}
}
}

View File

@@ -184,8 +184,16 @@ class ScontainersController
}
$data = $result['data'];
$containerId = (int)($data['id'] ?? 0);
if ($containerId <= 0) {
$routeId = (int)\Shared\Helpers\Helpers::get('id');
if ($routeId > 0) {
$containerId = $routeId;
}
}
$savedId = $this->repository->save([
'id' => (int)($data['id'] ?? 0),
'id' => $containerId,
'status' => $data['status'] ?? 0,
'show_title' => $data['show_title'] ?? 0,
'translations' => $data['translations'] ?? [],
@@ -240,7 +248,6 @@ class ScontainersController
];
$fields = [
FormField::hidden('id', $id),
FormField::langSection('translations', 'content', [
FormField::text('title', [
'label' => 'Tytul',
@@ -283,7 +290,7 @@ class ScontainersController
$actionUrl,
'/admin/scontainers/list/',
true,
[],
['id' => $id],
$languages,
$errors
);

View File

@@ -18,6 +18,8 @@ use admin\Support\TableListRequestFactory;
*/
class ShopProductController
{
private const CUSTOM_LABELS_SESSION_KEY = 'shop_product_show_custom_labels';
private ProductRepository $repository;
private IntegrationsRepository $integrationsRepository;
private LanguagesRepository $languagesRepository;
@@ -39,6 +41,8 @@ class ShopProductController
$apiloEnabled = $this->integrationsRepository->getSetting( 'apilo', 'enabled' );
$shopproEnabled = $this->integrationsRepository->getSetting( 'shoppro', 'enabled' );
$dlang = $this->languagesRepository->defaultLanguage();
$customLabelsEnabled = $this->customLabelsEnabled();
$customLabelNames = $this->repository->customLabelNames();
$sortableColumns = [ 'id', 'name', 'price_brutto', 'status', 'promoted', 'quantity' ];
@@ -98,6 +102,10 @@ class ShopProductController
. '<small class="text-muted product-categories product-categories--cats" title="' . $categories . '">' . $categories . '</small>'
. '<small class="text-muted product-categories">SKU: ' . $sku . ', EAN: ' . $ean . '</small>';
if ( $customLabelsEnabled ) {
$nameHtml .= $this->renderCustomLabelsEditor( $product, $id, $customLabelNames );
}
$priceHtml = '<input type="text" class="product-price form-control text-right" product-id="' . $id . '" value="' . htmlspecialchars( (string) $product['price_brutto'], ENT_QUOTES, 'UTF-8' ) . '" style="width: 75px;">';
$promoHtml = '<input type="text" class="product-price-promo form-control text-right" product-id="' . $id . '" value="' . htmlspecialchars( (string) $product['price_brutto_promo'], ENT_QUOTES, 'UTF-8' ) . '" style="width: 75px;">';
$promotedHtml = $product['promoted'] ? '<span class="text-success text-bold">tak</span>' : 'nie';
@@ -195,11 +203,25 @@ class ShopProductController
'viewModel' => $viewModel,
'apilo_enabled' => $apiloEnabled,
'shoppro_enabled' => $shopproEnabled,
'custom_labels_enabled' => $customLabelsEnabled,
] );
}
// ─── Krok 7: Edycja i zapis ─────────────────────────────────────
/**
* AJAX: przelacza widok custom labels na liscie produktow i zapisuje stan w sesji.
*/
public function product_custom_labels_toggle(): void
{
$currentState = $this->customLabelsEnabled();
$newState = $currentState ? 0 : 1;
\Shared\Helpers\Helpers::set_session( self::CUSTOM_LABELS_SESSION_KEY, $newState );
echo json_encode( [ 'status' => 'ok', 'enabled' => (bool) $newState ] );
exit;
}
/**
* Formularz edycji produktu.
*/
@@ -699,7 +721,8 @@ class ShopProductController
private function renderCustomFieldsBox( array $product ): string
{
$html = '<a href="#" class="btn btn-success" id="add_custom_field"><i class="fa fa-plus"></i> dodaj niestandardowe pole</a>';
$html = '<input type="hidden" name="custom_field_name_present" value="1">';
$html .= '<a href="#" class="btn btn-success" id="add_custom_field"><i class="fa fa-plus"></i> dodaj niestandardowe pole</a>';
$html .= '<div class="additional_fields pt-3">';
$customFields = is_array( $product['custom_fields'] ?? null ) ? $product['custom_fields'] : [];
@@ -896,9 +919,15 @@ class ShopProductController
public function product_custom_label_suggestions(): void
{
$response = [ 'status' => 'error', 'msg' => 'Podczas pobierania sugestii dla custom label wystąpił błąd. Proszę spróbować ponownie.' ];
$labelType = (string) \Shared\Helpers\Helpers::get( 'label_type' );
$suggestions = $this->repository->customLabelSuggestions( \Shared\Helpers\Helpers::get( 'custom_label' ), \Shared\Helpers\Helpers::get( 'label_type' ) );
if ( $suggestions ) {
if ( !$this->isAllowedCustomLabelType( $labelType ) ) {
echo json_encode( $response );
exit;
}
$suggestions = $this->repository->customLabelSuggestions( (string) \Shared\Helpers\Helpers::get( 'custom_label' ), $labelType );
if ( is_array( $suggestions ) ) {
$response = [ 'status' => 'ok', 'suggestions' => $suggestions ];
}
@@ -912,8 +941,14 @@ class ShopProductController
public function product_custom_label_save(): void
{
$response = [ 'status' => 'error', 'msg' => 'Podczas zapisywania custom label wystąpił błąd. Proszę spróbować ponownie.' ];
$labelType = (string) \Shared\Helpers\Helpers::get( 'label_type' );
if ( $this->repository->saveCustomLabel( (int) \Shared\Helpers\Helpers::get( 'product_id' ), \Shared\Helpers\Helpers::get( 'custom_label' ), \Shared\Helpers\Helpers::get( 'label_type' ) ) ) {
if ( !$this->isAllowedCustomLabelType( $labelType ) ) {
echo json_encode( $response );
exit;
}
if ( $this->repository->saveCustomLabel( (int) \Shared\Helpers\Helpers::get( 'product_id' ), (string) \Shared\Helpers\Helpers::get( 'custom_label' ), $labelType ) ) {
$response = [ 'status' => 'ok' ];
}
@@ -1196,4 +1231,36 @@ class ShopProductController
echo json_encode( [ 'status' => 'ok', 'products' => $products ] );
exit;
}
private function customLabelsEnabled(): bool
{
return isset( $_SESSION[ self::CUSTOM_LABELS_SESSION_KEY ] ) && (int) $_SESSION[ self::CUSTOM_LABELS_SESSION_KEY ] === 1;
}
private function isAllowedCustomLabelType(string $labelType): bool
{
return in_array( $labelType, [ 'custom_label_0', 'custom_label_1', 'custom_label_2', 'custom_label_3', 'custom_label_4' ], true );
}
private function renderCustomLabelsEditor(array $product, int $productId, array $customLabelNames): string
{
$customLabelsHtml = '<div class="custom-labels mt10">';
for ( $index = 0; $index < 5; $index++ ) {
$fieldName = 'custom_label_' . $index;
$labelText = htmlspecialchars( (string) ( $customLabelNames[$fieldName] ?? 'Custom label ' . $index ), ENT_QUOTES, 'UTF-8' );
$valueText = htmlspecialchars( (string) ( $product[$fieldName] ?? '' ), ENT_QUOTES, 'UTF-8' );
$customLabelsHtml .= '<div class="' . $fieldName . '_container">';
$customLabelsHtml .= '<small class="text-muted">' . $labelText . '</small>';
$datalistId = 'custom-label-list-' . $productId . '-' . $fieldName;
$customLabelsHtml .= '<input type="text" class="form-control input-sm product-custom-label" data-label-type="' . $fieldName . '" data-product-id="' . $productId . '" data-datalist-id="' . $datalistId . '" list="' . $datalistId . '" value="' . $valueText . '" placeholder="' . $labelText . '">';
$customLabelsHtml .= '<datalist id="' . $datalistId . '"></datalist>';
$customLabelsHtml .= '<div class="' . $fieldName . '_suggestions custom-label-suggestions"></div>';
$customLabelsHtml .= '</div>';
}
$customLabelsHtml .= '</div>';
return $customLabelsHtml;
}
}

View File

@@ -5,6 +5,7 @@ class ShopBasketController
{
private const ORDER_SUBMIT_TOKEN_SESSION_KEY = 'order-submit-token';
private const ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY = 'order-submit-last-order-id';
private const ORDER_SUBMIT_TOKEN_TTL = 1800;
public static $title = [
'mainView' => 'Koszyk'
@@ -276,55 +277,96 @@ class ShopBasketController
exit;
}
$existingOrderId = isset( $_SESSION[ self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY ] )
? (int)$_SESSION[ self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY ]
: 0;
if ( $existingOrderId > 0 )
{
$existingOrderHash = $this->orderRepository->findHashById( $existingOrderId );
if ( $existingOrderHash )
{
header( 'Location: /zamowienie/' . $existingOrderHash );
exit;
}
}
$client = \Shared\Helpers\Helpers::get_session( 'client' );
$orderSubmitToken = $this->createOrderSubmitToken();
$basket = \Shared\Helpers\Helpers::get_session( 'basket' );
$coupon = \Shared\Helpers\Helpers::get_session( 'coupon' );
$transport = ( new \Domain\Transport\TransportRepository( $GLOBALS['mdb'] ) )->findActiveByIdCached( \Shared\Helpers\Helpers::get_session( 'basket-transport-method-id' ) );
$productsSummary = (float)\Domain\Basket\BasketCalculator::summaryPrice( $basket, $coupon );
$freeDeliveryThreshold = isset( $settings['free_delivery'] ) ? (float)$settings['free_delivery'] : 0.0;
$transportCalc = $this->calculateTransportCostForSummary( $transport, $productsSummary, $freeDeliveryThreshold );
return \Shared\Tpl\Tpl::view( 'shop-basket/summary-view', [
'lang_id' => $lang_id,
'client' => \Shared\Helpers\Helpers::get_session( 'client' ),
'basket' => \Shared\Helpers\Helpers::get_session( 'basket' ),
'transport' => ( new \Domain\Transport\TransportRepository( $GLOBALS['mdb'] ) )->findActiveByIdCached( \Shared\Helpers\Helpers::get_session( 'basket-transport-method-id' ) ),
'basket' => $basket,
'transport' => $transport,
'transport_cost_effective' => $transportCalc['transport_cost_effective'],
'free_delivery_applies' => $transportCalc['free_delivery_applies'],
'payment_method' => $this->paymentMethodRepository->paymentMethodCached( (int)\Shared\Helpers\Helpers::get_session( 'basket-payment-method-id' ) ),
'addresses' => ( new \Domain\Client\ClientRepository( $GLOBALS['mdb'] ) )->clientAddresses( (int)$client['id'] ),
'settings' => $settings,
'coupon' => \Shared\Helpers\Helpers::get_session( 'coupon' ),
'coupon' => $coupon,
'basket_message' => \Shared\Helpers\Helpers::get_session( 'basket_message' ),
'order_submit_token' => $orderSubmitToken
] );
}
/**
* Wylicza efektywny koszt transportu dla widoku /koszyk-podsumowanie.
* Koszt spada do 0, gdy transport ma flage delivery_free=1 ORAZ wartosc koszyka
* (po kuponie) osiaga prog darmowej dostawy $freeDeliveryThreshold.
*
* @param array|null $transport Aktywny transport (lub null gdy nie wybrany)
* @param float $productsSummary Wartosc koszyka po kuponie
* @param float $freeDeliveryThreshold Prog darmowej dostawy z settings.free_delivery
* @return array{transport_cost_effective: float, free_delivery_applies: bool}
*/
protected function calculateTransportCostForSummary( $transport, $productsSummary, $freeDeliveryThreshold )
{
if ( !is_array( $transport ) )
{
return [
'transport_cost_effective' => 0.0,
'free_delivery_applies' => false,
];
}
$deliveryFree = isset( $transport['delivery_free'] ) ? (int)$transport['delivery_free'] : 0;
$cost = isset( $transport['cost'] ) ? (float)$transport['cost'] : 0.0;
$applies = false;
if ( $deliveryFree === 1 && $freeDeliveryThreshold > 0 )
{
$summaryNormalized = \Shared\Helpers\Helpers::normalize_decimal( $productsSummary );
$thresholdNormalized = \Shared\Helpers\Helpers::normalize_decimal( $freeDeliveryThreshold );
if ( $summaryNormalized >= $thresholdNormalized )
{
$applies = true;
}
}
return [
'transport_cost_effective' => $applies ? 0.0 : $cost,
'free_delivery_applies' => $applies,
];
}
public function basketSave()
{
$orderSubmitToken = (string)\Shared\Helpers\Helpers::get( 'order_submit_token', true );
$existingOrderId = isset( $_SESSION[ self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY ] ) ? (int)$_SESSION[ self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY ] : 0;
$basket = \Shared\Helpers\Helpers::get_session( 'basket' );
if ( empty( $basket ) && $existingOrderId > 0 )
{
$existingOrderHash = $this->orderRepository->findHashById( $existingOrderId );
if ( $existingOrderHash )
{
$this->logOrder( 'Double-submit detected, redirecting to existing order id=' . $existingOrderId );
header( 'Location: /zamowienie/' . $existingOrderHash );
exit;
}
}
if ( !$this->isValidOrderSubmitToken( $orderSubmitToken ) )
{
if ( $existingOrderId > 0 )
{
$existingOrderHash = $this->orderRepository->findHashById( $existingOrderId );
if ( $existingOrderHash )
{
header( 'Location: /zamowienie/' . $existingOrderHash );
exit;
}
}
$this->logOrder( 'Token validation failed. formToken=' . $orderSubmitToken . ' existingOrderId=' . $existingOrderId );
\Shared\Helpers\Helpers::error( \Shared\Helpers\Helpers::lang( 'zamowienie-zostalo-zlozone-komunikat-blad' ) );
header( 'Location: /koszyk' );
header( 'Location: /koszyk-podsumowanie' );
exit;
}
@@ -367,6 +409,7 @@ class ShopBasketController
}
catch ( \Exception $e )
{
$this->logOrder( 'createFromBasket exception: ' . $e->getMessage() );
error_log( '[basketSave] createFromBasket exception: ' . $e->getMessage() );
\Shared\Helpers\Helpers::error( \Shared\Helpers\Helpers::lang( 'zamowienie-zostalo-zlozone-komunikat-blad' ) );
header( 'Location: /koszyk' );
@@ -400,6 +443,7 @@ class ShopBasketController
}
else
{
$this->logOrder( 'createFromBasket returned falsy order_id. client_id=' . ( $client['id'] ?? '?' ) . ' email=' . ( \Shared\Helpers\Helpers::get( 'email', true ) ?: '?' ) );
\Shared\Helpers\Helpers::error( \Shared\Helpers\Helpers::lang( 'zamowienie-zostalo-zlozone-komunikat-blad' ) );
header( 'Location: /koszyk' );
exit;
@@ -446,6 +490,79 @@ class ShopBasketController
] );
}
public function basketUpdateCustomFields()
{
$basket = \Shared\Helpers\Helpers::get_session( 'basket' );
$product_code = \Shared\Helpers\Helpers::get( 'product_code' );
if ( !isset( $basket[ $product_code ] ) )
{
echo json_encode( [ 'result' => 'error', 'message' => 'Pozycja nie istnieje w koszyku' ] );
exit;
}
$position = $basket[ $product_code ];
$new_custom_fields = [];
$custom_fields_raw = \Shared\Helpers\Helpers::get( 'custom_field' );
if ( is_array( $custom_fields_raw ) )
{
foreach ( $custom_fields_raw as $field_id => $value )
{
$new_custom_fields[ (int)$field_id ] = $value;
}
}
$productRepo = new \Domain\Product\ProductRepository( $GLOBALS['mdb'] );
$missing_fields = [];
foreach ( $new_custom_fields as $field_id => $value )
{
$field_meta = $productRepo->findCustomFieldCached( $field_id );
if ( $field_meta && (int)$field_meta['is_required'] === 1 && trim( $value ) === '' )
{
$missing_fields[] = $field_meta['name'];
}
}
if ( count( $missing_fields ) > 0 )
{
echo json_encode( [ 'result' => 'error', 'message' => 'Wypełnij wymagane pola: ' . implode( ', ', $missing_fields ) ] );
exit;
}
$attributes_implode = '';
if ( isset( $position['attributes'] ) && is_array( $position['attributes'] ) && count( $position['attributes'] ) > 0 )
{
$attributes_implode = implode( '|', $position['attributes'] );
}
$message = isset( $position['message'] ) ? $position['message'] : '';
$new_product_code = md5( $position['product-id'] . $attributes_implode . $message . json_encode( $new_custom_fields ) );
if ( $new_product_code === $product_code )
{
$basket[ $product_code ]['custom_fields'] = $new_custom_fields;
}
elseif ( isset( $basket[ $new_product_code ] ) )
{
$basket[ $new_product_code ]['quantity'] += $position['quantity'];
unset( $basket[ $product_code ] );
}
else
{
$position['custom_fields'] = $new_custom_fields;
$basket[ $new_product_code ] = $position;
unset( $basket[ $product_code ] );
}
$basket = ( new \Domain\Promotion\PromotionRepository( $GLOBALS['mdb'] ) )->findPromotion( $basket );
\Shared\Helpers\Helpers::set_session( 'basket', $basket );
echo json_encode( [ 'result' => 'ok' ] );
exit;
}
private function jsonBasketResponse( $basket, $coupon, $lang_id, $basket_transport_method_id )
{
global $settings;
@@ -471,8 +588,23 @@ class ShopBasketController
private function createOrderSubmitToken()
{
$sessionData = isset( $_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ] )
? $_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ]
: null;
if ( is_array( $sessionData ) && isset( $sessionData['token'], $sessionData['created_at'] ) )
{
if ( ( time() - $sessionData['created_at'] ) < self::ORDER_SUBMIT_TOKEN_TTL )
{
return $sessionData['token'];
}
}
$token = $this->generateOrderSubmitToken();
\Shared\Helpers\Helpers::set_session( self::ORDER_SUBMIT_TOKEN_SESSION_KEY, $token );
\Shared\Helpers\Helpers::set_session( self::ORDER_SUBMIT_TOKEN_SESSION_KEY, [
'token' => $token,
'created_at' => time()
] );
\Shared\Helpers\Helpers::delete_session( self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY );
return $token;
@@ -495,10 +627,29 @@ class ShopBasketController
if ( !$token )
return false;
$sessionToken = isset( $_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ] ) ? (string)$_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ] : '';
if ( !$sessionToken )
$sessionData = isset( $_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ] )
? $_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ]
: null;
if ( !$sessionData )
return false;
// Backward compatibility: stary format (plain string)
if ( is_string( $sessionData ) )
{
$sessionToken = $sessionData;
}
elseif ( is_array( $sessionData ) && isset( $sessionData['token'], $sessionData['created_at'] ) )
{
if ( ( time() - $sessionData['created_at'] ) >= self::ORDER_SUBMIT_TOKEN_TTL )
return false;
$sessionToken = $sessionData['token'];
}
else
{
return false;
}
if ( function_exists( 'hash_equals' ) )
return hash_equals( $sessionToken, $token );
@@ -509,4 +660,11 @@ class ShopBasketController
{
\Shared\Helpers\Helpers::delete_session( self::ORDER_SUBMIT_TOKEN_SESSION_KEY );
}
private function logOrder( $message )
{
$logFile = __DIR__ . '/../../../logs/logs-order-' . date( 'Y-m-d' ) . '.log';
$line = '[' . date( 'Y-m-d H:i:s' ) . '] ' . $message . "\n";
@file_put_contents( $logFile, $line, FILE_APPEND );
}
}

View File

@@ -193,7 +193,8 @@ class LayoutEngine
//
if ( \Shared\Helpers\Helpers::get( 'product' ) )
{
$product = ( new \Domain\Product\ProductRepository( $GLOBALS['mdb'] ) )->findCached( \Shared\Helpers\Helpers::get( 'product' ), $lang_id, $_GET['permutation_hash'] ?? null );
$permutation_hash = isset( $_GET['permutation_hash'] ) ? str_replace( '_', '|', $_GET['permutation_hash'] ) : null;
$product = ( new \Domain\Product\ProductRepository( $GLOBALS['mdb'] ) )->findCached( \Shared\Helpers\Helpers::get( 'product' ), $lang_id, $permutation_hash );
if ( $product['language']['meta_title'] )
$page['language']['title'] = $product['language']['meta_title'];

View File

@@ -19,4 +19,9 @@ $config['trustmate']['enabled'] = true;
$config['trustmate']['uid'] = '34eb36ba-c715-4cdc-8707-22376c9f14c7';
$config['cron_key'] = 'Gi7FzWtkry19hZ1BqT1LKEWfwokQpigh';
$database_inst['host_remote'] = 'host700513.hostido.net.pl';
$database_inst['user'] = 'host700513_pomysloweprezenty-pl';
$database_inst['password'] = 'QBVbveHAzR78UN8pc7Um';
$database_inst['name'] = 'host700513_pomysloweprezenty-pl';
?>

View File

@@ -521,6 +521,17 @@ $processor->registerHandler( \Domain\CronJob\CronJobType::APILO_SEND_ORDER, func
{
$mdb->update( 'pp_shop_orders', [ 'apilo_order_id' => $response['id'] ], [ 'id' => $order['id'] ] );
\Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Zamówienie wysłane do Apilo (apilo_order_id: ' . $response['id'] . ')', [ 'http_code' => $http_code_send, 'response' => $response ] );
// Wyczyść stare stuck joby sync_payment/sync_status dla tego zamówienia
$orderPayloadJson = json_encode(['order_id' => (int)$order['id']]);
$mdb->delete('pp_cron_jobs', [
'AND' => [
'job_type' => [\Domain\CronJob\CronJobType::APILO_SYNC_PAYMENT, \Domain\CronJob\CronJobType::APILO_SYNC_STATUS],
'payload' => $orderPayloadJson,
'status' => [\Domain\CronJob\CronJobType::STATUS_PENDING, \Domain\CronJob\CronJobType::STATUS_FAILED],
]
]);
echo '<p>Wysłałem zamówienie do apilo.com: ID: ' . $order['id'] . ' - ' . $response['id'] . '</p>';
}
}
@@ -760,7 +771,8 @@ $processor->registerHandler( \Domain\CronJob\CronJobType::TRUSTMATE_INVITATION,
$result = $processor->run( 20 );
// Powiadomienie mailowe o trwale nieudanych zadaniach Apilo
// Powiadomienie mailowe o problemach Apilo
// 1. Trwale failed joby (nie-order: token, product sync itp.)
$failedApiloJobs = $mdb->select('pp_cron_jobs', ['id', 'job_type', 'last_error', 'payload', 'attempts', 'completed_at'], [
'AND' => [
'status' => 'failed',
@@ -768,16 +780,51 @@ $failedApiloJobs = $mdb->select('pp_cron_jobs', ['id', 'job_type', 'last_error',
'completed_at[>=]' => date('Y-m-d H:i:s', time() - 120),
]
]);
if (!empty($failedApiloJobs)) {
$emailBody = "Następujące zadania Apilo zakończyły się trwałym błędem (wyczerpano limit prób):\n\n";
foreach ($failedApiloJobs as $fj) {
$emailBody .= "Job #" . $fj['id'] . " (" . $fj['job_type'] . ")\n";
$emailBody .= " Payload: " . $fj['payload'] . "\n";
// 2. Order joby z wieloma próbami (infinite retry, ale wymagają uwagi)
$stuckOrderJobs = $mdb->select('pp_cron_jobs', ['id', 'job_type', 'last_error', 'payload', 'attempts', 'scheduled_at'], [
'AND' => [
'status' => 'pending',
'job_type' => [\Domain\CronJob\CronJobType::APILO_SEND_ORDER, \Domain\CronJob\CronJobType::APILO_SYNC_PAYMENT, \Domain\CronJob\CronJobType::APILO_SYNC_STATUS],
'attempts[>=]' => 10,
]
]);
$allProblems = array_merge($failedApiloJobs, $stuckOrderJobs);
if (!empty($allProblems)) {
$emailBody = "";
$orderNumbers = [];
foreach ($allProblems as $fj) {
$payloadData = is_string($fj['payload']) ? json_decode($fj['payload'], true) : $fj['payload'];
$orderId = isset($payloadData['order_id']) ? (int)$payloadData['order_id'] : 0;
$isOrderJob = \Domain\CronJob\CronJobType::isOrderRelatedApiloJob($fj['job_type']);
$statusLabel = $isOrderJob ? 'PONAWIANY CO 30 MIN' : 'TRWAŁY BŁĄD';
$emailBody .= "Job #" . $fj['id'] . " (" . $fj['job_type'] . ") — " . $statusLabel . "\n";
if ($orderId > 0) {
$order = $mdb->get('pp_shop_orders', ['id', 'client_name', 'client_surname', 'date_order', 'summary'], ['id' => $orderId]);
if ($order) {
$emailBody .= " Zamówienie: #" . $order['id'] . "\n";
$emailBody .= " Klient: " . trim($order['client_name'] . ' ' . $order['client_surname']) . "\n";
$emailBody .= " Data zamówienia: " . $order['date_order'] . "\n";
$emailBody .= " Kwota: " . $order['summary'] . " PLN\n";
$orderNumbers[] = '#' . $order['id'];
}
}
$emailBody .= " Próby: " . $fj['attempts'] . "\n";
$emailBody .= " Błąd: " . $fj['last_error'] . "\n";
$emailBody .= " Data: " . $fj['completed_at'] . "\n\n";
$emailBody .= " Data: " . ($fj['completed_at'] ? $fj['completed_at'] : $fj['scheduled_at']) . "\n\n";
}
\Shared\Helpers\Helpers::send_email('biuro@project-pro.pl', 'shopPRO: Trwały błąd synchronizacji Apilo (' . count($failedApiloJobs) . ' zadań)', $emailBody);
$subject = 'shopPRO: Problemy synchronizacji Apilo';
if (!empty($orderNumbers)) {
$subject .= ' — zamówienia ' . implode(', ', array_unique($orderNumbers));
}
$subject .= ' (' . count($allProblems) . ' zadań)';
\Shared\Helpers\Helpers::send_email('biuro@project-pro.pl', $subject, $emailBody);
}
echo '<hr>';

View File

@@ -1,147 +0,0 @@
# CARL — Dynamiczne reguły dla Claude Code
[CARL](https://github.com/ChristopherKahler/carl) (Context Augmentation & Reinforcement Layer) to system wstrzykiwania reguł do Claude Code — reguły ładują się **tylko gdy są potrzebne**, zamiast zapychać kontekst sesji regułami, których akurat nie używasz.
Zainstalowany globalnie w `~/.carl/`.
---
## Jak to działa
Claude Code ma ograniczony kontekst sesji. CARL rozwiązuje problem "zbyt wielu reguł na raz":
- **Domeny** — zestawy reguł uruchamiane automatycznie przez słowa kluczowe w prompcie
- **Star-commands** (`*dev`, `*review`, itp.) — tryby uruchamiane ręcznie przez wpisanie `*nazwatrybe`
- **Global** — reguły zawsze aktywne (minimalne, uniwersalne)
Efekt: zamiast 50 reguł na każdą sesję, Claude dostaje 510 tych, które są relevantne teraz.
---
## Struktura plików
```
~/.carl/ # Globalna konfiguracja (wszystkie projekty)
├── manifest # Rejestr domen (stany + słowa kluczowe)
├── global # Reguły zawsze aktywne
├── commands # Definicje star-commands
├── context # Reguły kontekstowe (rozmiar okna kontekstu)
└── {nazwa-domeny} # Twoja domena (bez rozszerzenia!)
.carl/ # Konfiguracja lokalna (tylko ten projekt)
└── {nazwa-domeny} # Reguły specyficzne dla shopPRO
```
**Ważne:** Nazwy plików domen — **małe litery, bez rozszerzenia** (`phpdev`, nie `phpdev.carl`).
---
## Dostępne star-commands
Wpisz `*nazwa` na początku wiadomości lub w środku promptu, żeby przełączyć tryb:
| Komenda | Tryb | Kiedy używać |
|---------|------|-------------|
| `*dev` | Development | Implementacja, szybkie zmiany bez tłumaczeń |
| `*review` | Code review | Przegląd kodu, bezpieczeństwo, edge cases |
| `*brief` | Zwięzłe odpowiedzi | Tylko bullet points, bez elaboracji |
| `*plan` | Planowanie | Eksploracja przed implementacją, opcje + tradeoffs |
| `*discuss` | Dyskusja | Burza mózgów, wiele podejść, bez skakania do kodu |
| `*debug` | Debugowanie | Systematyczna diagnoza, root cause analysis |
| `*explain` | Wyjaśnianie | Nauka, koncepty, stopniowe budowanie wiedzy |
| `*carl` | Pomoc CARL | Zarządzanie domenami, konfiguracja, pytania o CARL |
### Przykłady
```
*dev Napraw błąd w summaryView() gdzie duplikuje zamówienia
*review Przejrzyj OrderRepository::createFromBasket pod kątem bezpieczeństwa
*brief Co robi CacheHandler::deletePattern()
*plan Chcę dodać system rabatów do koszyka
*discuss Czy lepiej rozdzielić ApiloRepository na sync i admin?
```
---
## Tworzenie własnej domeny (projekt)
Kiedy masz zestaw reguł specyficznych dla shopPRO, utwórz lokalną domenę w `.carl/`.
### Krok 1 — Plik domeny
Utwórz `.carl/shoppro` (bez rozszerzenia):
```
# shopPRO Domain Rules
SHOPPRO_RULE_0=PHP < 8.0 — nie używaj match, named args, union types, str_contains
SHOPPRO_RULE_1=ORM: Medoo ($mdb), zawsze prepared statements, nigdy string concatenation
SHOPPRO_RULE_2=Namespace \Domain\ mapuje do autoload/Domain/ (D uppercase, a lowercase)
SHOPPRO_RULE_3=Testy: PHPUnit 9.6, pattern AAA, mock Medoo przez createMock(\medoo::class)
SHOPPRO_RULE_4=Cache: CacheHandler::deletePattern() do kasowania, TTL 86400, dane serialized
```
### Krok 2 — Wpis w manifeście
Dodaj do `.carl/manifest` (lub `~/.carl/manifest` jeśli globalna):
```
SHOPPRO_STATE=active
SHOPPRO_RECALL=shopPRO, medoo, zamówienie, koszyk, OrderRepository, Domain
SHOPPRO_EXCLUDE=
SHOPPRO_ALWAYS_ON=false
```
- `RECALL` — słowa kluczowe które triggerują domenę (przecinek = OR)
- `ALWAYS_ON=true` — ładuj przy każdym prompcie (tylko dla naprawdę universalnych reguł)
- `EXCLUDE` — słowa które blokują domenę mimo dopasowania RECALL
### Krok 3 — Weryfikacja
Wpisz `*carl` w czacie i zapytaj: _"Pokaż mi aktywne domeny"_.
---
## Zarządzanie przez Claude
Zamiast ręcznie edytować pliki, możesz zarządzać CARL przez Claude:
```
*carl Dodaj domenę dla testów PHPUnit w shopPRO
*carl Pokaż moją aktualną konfigurację
*carl Wyłącz domenę SHOPPRO tymczasowo
*carl Dodaj regułę do domeny dev: zawsze uruchamiaj ./test.ps1 po zmianach
```
Claude użyje skills `carl:manager` / `carl:tasks:*` do operacji na plikach.
---
## Integracja z PAUL
CARL i [PAUL](./PAUL_WORKFLOW.md) działają uzupełniająco:
- **PAUL** strukturyzuje *proces* (plan → apply → unify)
- **CARL** dostarcza *reguły domenowe* wtedy gdy są potrzebne
Praktycznie: podczas `/paul:apply` możesz prefixować `*dev` żeby Claude skupił się na kodzie bez elaboracji. Podczas `/paul:discuss``*discuss` żeby dostać pełną analizę opcji.
---
## Dobre praktyki
- **RECALL słowa** — używaj konkretnych, rzadkich słów żeby unikać false triggers. `medoo` lepsze niż `php`.
- **Mało reguł per domena** — 58 reguł to optymalnie. Więcej = wolniejsze matching, więcej tokenów.
- **ALWAYS_ON=false** domyślnie — ALWAYS_ON=true tylko dla reguł naprawdę universalnych (jak GLOBAL).
- **Star-commands przy dużych taskach** — na początku sesji wpisz `*dev` lub `*plan` żeby ustawić tryb.
- **Nie duplikuj CLAUDE.md** — CARL nie zastępuje CLAUDE.md. CLAUDE.md to architektura projektu. CARL to reguły zachowania Claude.
---
*Docs: 2026-03-12*

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,156 +0,0 @@
# Plan: System kolejki zadań cron oparty o bazę danych
## Kontekst
Obecny system cron ma dwa problemy:
1. **Kolejka plikowa (JSON)** — sync płatności/statusów Apilo trzymany w `/temp/apilo-sync-queue.json` — kruchy, brak transakcji, ryzyko utraty danych
2. **Monolityczny cron.php** (~550 linii) — brak priorytetów, brak retry z backoff, brak centralnego zarządzania
Cel: Zastąpienie całego systemu cron tabelą `pp_cron_jobs` z priorytetami, retry/backoff i harmonogramem `pp_cron_schedules`.
## Nowe pliki
| Plik | Opis |
|------|------|
| `autoload/Domain/CronJob/CronJobType.php` | Stałe typów zadań i priorytetów |
| `autoload/Domain/CronJob/CronJobRepository.php` | CRUD na `pp_cron_jobs` + `pp_cron_schedules` |
| `autoload/Domain/CronJob/CronJobProcessor.php` | Orkiestracja: pobierz zadanie → wywołaj handler → obsłuż wynik |
| `tests/Unit/Domain/CronJob/CronJobTypeTest.php` | Testy stałych |
| `tests/Unit/Domain/CronJob/CronJobRepositoryTest.php` | Testy repozytorium |
| `tests/Unit/Domain/CronJob/CronJobProcessorTest.php` | Testy procesora |
| `migrations/0.315.sql` | CREATE TABLE + INSERT harmonogramów |
## Modyfikowane pliki
| Plik | Zmiana |
|------|--------|
| `cron.php` | Zastąpienie ~550 linii orchestratorem (~100 linii) z rejestracją handlerów |
| `cron/cron-xml.php` | Usunięcie — logika przeniesiona do handlera `google_xml_feed` |
| `cron-turstmate.php` | Usunięcie — logika przeniesiona do handlera `trustmate_invitation` |
| `autoload/Domain/Order/OrderAdminService.php` | `queueApiloSync()` → enqueue do DB; usunięcie metod plikowych; `syncApiloPayment()`/`syncApiloStatus()` → public |
| `tests/Unit/Domain/Order/OrderAdminServiceTest.php` | Refaktor testów kolejki: mock `CronJobRepository` zamiast pliku JSON |
| `docs/DATABASE_STRUCTURE.md` | Dodanie tabel `pp_cron_jobs`, `pp_cron_schedules` |
| `docs/CHANGELOG.md` | Wpis o nowym systemie |
## Schemat DB (`migrations/0.315.sql`)
### `pp_cron_jobs`
```sql
CREATE TABLE pp_cron_jobs (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
job_type VARCHAR(50) NOT NULL,
status ENUM('pending','processing','completed','failed','cancelled') NOT NULL DEFAULT 'pending',
priority TINYINT UNSIGNED NOT NULL DEFAULT 100, -- niższy = ważniejszy
payload TEXT NULL, -- JSON z danymi zadania
result TEXT NULL, -- JSON z wynikiem
attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0,
max_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 10,
last_error VARCHAR(500) NULL,
scheduled_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at DATETIME NULL,
completed_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status_priority_scheduled (status, priority, scheduled_at),
INDEX idx_job_type (job_type),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
```
### `pp_cron_schedules`
```sql
CREATE TABLE pp_cron_schedules (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
job_type VARCHAR(50) NOT NULL UNIQUE,
interval_seconds INT UNSIGNED NOT NULL,
priority TINYINT UNSIGNED NOT NULL DEFAULT 100,
max_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 3,
payload TEXT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
last_run_at DATETIME NULL,
next_run_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_enabled_next_run (enabled, next_run_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
```
## Typy zadań i priorytety
| Typ | Priorytet | Harmonogram |
|-----|-----------|-------------|
| `apilo_token_keepalive` | 10 (krytyczny) | co 4 min |
| `apilo_send_order` | 50 (wysoki) | co 1 min |
| `apilo_sync_payment` | 50 (wysoki) | event-driven (enqueue przy zmianie) |
| `apilo_sync_status` | 50 (wysoki) | event-driven |
| `apilo_product_sync` | 100 (normalny) | co 10 min |
| `apilo_pricelist_sync` | 100 (normalny) | co 1h |
| `apilo_status_poll` | 100 (normalny) | co 10 min |
| `price_history` | 100 (normalny) | co 24h |
| `order_analysis` | 100 (normalny) | co 10 min |
| `trustmate_invitation` | 200 (niski) | co 10 min |
| `google_xml_feed` | 200 (niski) | co 1h |
## Architektura klas
### CronJobRepository — metody kluczowe
- `enqueue($jobType, $payload, $priority, $maxAttempts, $scheduledAt)` — dodaj do kolejki
- `fetchNext($limit)` — atomowe pobranie pending jobs (UPDATE WHERE status='pending')
- `markCompleted($jobId, $result)` / `markFailed($jobId, $error, $backoffSeconds)`
- `hasPendingJob($jobType, $payloadMatch)` — zapobiega duplikatom
- `cleanup($olderThanDays)` — GC starych wpisów
- `recoverStuck($olderThanMinutes)` — reset stuck "processing" jobs
- `getDueSchedules()` / `touchSchedule($id)` — harmonogram
### CronJobProcessor — orkiestracja
- `registerHandler($jobType, callable)` — rejestracja handlera
- `createScheduledJobs()` — tworzy jobs z harmonogramów których `next_run_at <= NOW`
- `processQueue($limit)` — pobierz + wywołaj handler + markCompleted/markFailed
- `run($limit)` — główna metoda: schedules + process
### Exponential backoff
```
Próba 1: 60s, Próba 2: 120s, Próba 3: 240s, ... max 3600s (1h)
```
### Zależność "order not yet in Apilo"
Handler `apilo_sync_payment`/`apilo_sync_status` sprawdza `apilo_order_id`. Jeśli brak → zwraca false → `markFailed()` z backoffem → zadanie wraca do kolejki. Max 50 prób.
## Nowy cron.php (schemat)
```php
$cronRepo = new \Domain\CronJob\CronJobRepository($mdb);
$processor = new \Domain\CronJob\CronJobProcessor($mdb, $cronRepo);
// Rejestracja handlerów (każdy to callable)
$processor->registerHandler('apilo_token_keepalive', function($payload) use ($integrationsRepo) { ... });
$processor->registerHandler('apilo_send_order', function($payload) use ($orderService, ...) { ... });
// ... inne handlery
$result = $processor->run(20);
```
## Zmiany w OrderAdminService
1. `queueApiloSync()``CronJobRepository::enqueue()` zamiast zapisu do pliku JSON
2. Usunięcie: `loadApiloSyncQueue()`, `saveApiloSyncQueue()`, `apiloSyncQueuePath()`, stała `APILO_SYNC_QUEUE_FILE`
3. `syncApiloPayment()`, `syncApiloStatus()` → zmiana z `private` na `public`
4. Jednorazowa migracja: odczyt JSON → insert do DB → usunięcie pliku
## Kolejność implementacji
1. Migracja SQL
2. `CronJobType.php`
3. `CronJobRepository.php` + testy
4. `CronJobProcessor.php` + testy
5. Modyfikacja `OrderAdminService` (queue → DB, public methods)
6. Jednorazowa migracja pliku JSON → DB
7. Nowy `cron.php` z handlerami (ekstrakcja logiki z bloków proceduralnych)
8. Aktualizacja testów OrderAdminService
9. Dokumentacja (DATABASE_STRUCTURE.md, CHANGELOG.md)
## Weryfikacja
1. Uruchomienie pełnego zestawu testów: `./test.ps1`
2. Sprawdzenie czy nowe testy CronJob* przechodzą
3. Sprawdzenie czy istniejące testy OrderAdminService przechodzą po refaktorze
4. Weryfikacja migracji SQL na pustej bazie

View File

@@ -1,730 +0,0 @@
# Struktura bazy danych shopPRO
Plik aktualizowany na bieżąco przy zmianach w kodzie.
ORM: Medoo (`$mdb`), prefix tabel: `pp_`
## pp_shop_products
Główna tabela produktów.
| Kolumna | Opis |
|---------|------|
| id | PK |
| parent_id | FK do produktu nadrzędnego (kombinacje) - NULL dla produktów głównych |
| price_brutto | Cena brutto |
| price_brutto_promo | Cena promocyjna brutto |
| quantity | Stan magazynowy |
| status | Status: 1 = aktywny, 0 = nieaktywny |
| archive | Archiwum: 1 = zarchiwizowany, 0 = aktywny |
| promoted | Czy promowany |
| vat | Stawka VAT |
| ean | Kod EAN |
| sku | Kod SKU |
| apilo_product_id | ID produktu w Apilo |
| apilo_product_name | Nazwa produktu w Apilo |
**Używane w:** `Domain\Product\ProductRepository`, `admin\factory\ShopProduct`, `admin\Controllers\ShopProductController`
## pp_shop_products_langs
Tłumaczenia produktów (per język).
| Kolumna | Opis |
|---------|------|
| id | PK |
| product_id | FK do pp_shop_products |
| lang_id | ID języka (np. 'pl') |
| name | Nazwa produktu |
**Używane w:** `Domain\Product\ProductRepository::getName()`
## pp_shop_products_images
Zdjęcia produktów.
| Kolumna | Opis |
|---------|------|
| id | PK |
| product_id | FK do pp_shop_products |
| src | Ścieżka do pliku |
| alt | Tekst alternatywny |
## pp_shop_products_custom_fields
Dodatkowe pola produktów (custom fields).
| Kolumna | Opis |
|---------|------|
| id_additional_field | PK |
| id_product | FK do pp_shop_products |
| name | Nazwa pola |
| type | Typ pola (VARCHAR 30) |
| is_required | Czy wymagane (0/1) |
## pp_shop_products_categories
Przypisanie produktów do kategorii.
| Kolumna | Opis |
|---------|------|
| product_id | FK do pp_shop_products |
**Używane w:** `admin\factory\ShopProduct::product_delete()`, `Domain\Product\ProductRepository::getProductsByCategory()`
**Aktualizacja 2026-02-15 (ver. 0.274):** akcje `/admin/shop_product/mass_edit/*` korzystają z `Domain\Product\ProductRepository` przez `admin\Controllers\ShopProductController`.
## pp_shop_categories
Kategorie sklepu.
| Kolumna | Opis |
|---------|------|
| id | PK |
| parent_id | FK do kategorii nadrzednej (NULL dla root) |
| status | 1 = aktywna, 0 = nieaktywna |
| o | Kolejnosc wyswietlania |
| sort_type | Typ sortowania produktow w kategorii |
| view_subcategories | Czy wyswietlac podkategorie |
**Uzywane w:** `Domain\Category\CategoryRepository`, `admin\Controllers\ShopCategoryController`.
## pp_shop_categories_langs
Tlumaczenia kategorii (per jezyk).
| Kolumna | Opis |
|---------|------|
| category_id | FK do pp_shop_categories |
| lang_id | ID jezyka (np. pl, en) |
| title | Nazwa kategorii |
| text | Opis kategorii |
| text_hidden | Rozwiniecie opisu kategorii |
| seo_link | Link SEO kategorii |
| meta_title | Meta title |
| meta_description | Meta description |
| meta_keywords | Meta keywords |
| noindex | Flaga noindex |
| category_title | Naglowek H1 kategorii |
| additional_text | Dodatkowy tekst nad lista produktow |
**Uzywane w:** `Domain\Category\CategoryRepository`, `admin\Controllers\ShopCategoryController`.
**Aktualizacja 2026-02-15 (ver. 0.275):** modul `/admin/shop_category/*` korzysta z `Domain\Category\CategoryRepository` przez `admin\Controllers\ShopCategoryController`; usunieto legacy `admin\controls/factory/view\ShopCategory`.
## pp_shop_orders
Zamówienia sklepu (źródło danych dla list i szczegółów klientów w panelu admin).
| Kolumna | Opis |
|---------|------|
| id | PK |
| client_id | FK do `pp_shop_clients` (NULL dla gościa) |
| client_name | Imię klienta z zamówienia |
| client_surname | Nazwisko klienta z zamówienia |
| client_email | E-mail klienta z zamówienia |
| client_phone | Telefon klienta |
| client_city | Miasto klienta |
| summary | Wartość zamówienia |
| date_order | Data złożenia zamówienia |
| payment_method | Nazwa metody płatności |
| transport | Nazwa transportu |
| message | Wiadomość klienta |
| updated_at | Data ostatniej modyfikacji (polling API) |
**Używane w:** `Domain\Client\ClientRepository::listForAdmin()`, `Domain\Client\ClientRepository::ordersForClient()`, `Domain\Client\ClientRepository::totalsForClient()`, `Domain\Order\OrderRepository::listForApi()`, `Domain\Order\OrderRepository::findForApi()`.
**Aktualizacja 2026-02-15 (ver. 0.274):** moduł `/admin/shop_clients/*` korzysta z `Domain\Client\ClientRepository` przez `admin\Controllers\ShopClientsController`.
**Aktualizacja 2026-02-15 (ver. 0.276):** moduł `/admin/shop_order/*` korzysta z `Domain\Order\OrderRepository` przez `admin\Controllers\ShopOrderController`; usunięto legacy `admin\controls\ShopOrder` i `admin\factory\ShopOrder`.
**Aktualizacja 2026-02-17 (ver. 0.290):** frontend `/shop_order/*` korzysta z `Domain\Order\OrderRepository` przez `front\Controllers\ShopOrderController`; usunięto legacy `front\controls\ShopOrder`, `front\factory\ShopOrder`, `front\view\ShopOrder`. Callery (ShopBasketController, ClientRepository, shop\Order, cron-turstmate) przepięte na OrderRepository.
## pp_banners
Banery.
| Kolumna | Opis |
|---------|------|
| id | PK |
| name | Nazwa banera |
| status | 0/1 |
| date_start | Data rozpoczęcia |
| date_end | Data zakończenia |
| home_page | Czy na stronie głównej 0/1 |
**Używane w:** `Domain\Banner\BannerRepository`, `front\Views\Banners`
**Aktualizacja 2026-02-16 (ver. 0.281):** metody frontendowe `banners()`, `mainBanner()` dodane do `Domain\Banner\BannerRepository`. Fasady `front\factory\Banners` i `front\view\Banners` deleguja do repo/Views.
## pp_banners_langs
Tłumaczenia banerów.
| Kolumna | Opis |
|---------|------|
| id | PK |
| id_banner | FK do pp_banners |
| id_lang | ID języka |
| src | Ścieżka do grafiki |
| url | URL docelowy |
| html | Kod HTML |
| text | Tekst |
**Używane w:** `Domain\Banner\BannerRepository`, `front\Views\Banners`
## pp_articles
Artykuły.
| Kolumna | Opis |
|---------|------|
| id | PK |
| status | -1 = archiwum, 0 = nieaktywny, 1 = aktywny |
**Używane w:** `admin\Controllers\ArticlesArchiveController`, `Domain\Article\ArticleRepository::find()`, `Domain\Article\ArticleRepository::listArchivedForAdmin()`
## pp_articles_pages
Strony artykułów.
| Kolumna | Opis |
|---------|------|
| article_id | FK do pp_articles |
| page_id | FK do strony (pp_pages) |
| o | Kolejność |
**Używane w:** `Domain\Article\ArticleRepository::find()`, `Domain\Article\ArticleRepository::deleteNonassignedImages()`
## pp_articles_langs
Tłumaczenia artykułów.
| Kolumna | Opis |
|---------|------|
| article_id | FK do pp_articles |
| lang_id | ID języka (np. 'pl') |
| title | Tytuł artykułu |
| seo_link | Link SEO artykułu |
**Używane w:** `Domain\Article\ArticleRepository::find()`, `Domain\Article\ArticleRepository::deleteNonassignedFiles()`
## pp_articles_images
Zdjęcia artykułów.
| Kolumna | Opis |
|---------|------|
| article_id | FK do pp_articles |
| src | Ścieżka do pliku |
| o | Kolejność |
| id | PK (używane też do sortowania DESC) |
**Używane w:** `Domain\Article\ArticleRepository::find()`
## pp_articles_files
Pliki artykułów.
| Kolumna | Opis |
|---------|------|
| id | PK |
| article_id | FK do pp_articles |
| src | Ścieżka do pliku |
| name | Nazwa wyświetlana załącznika (opcjonalna) |
| to_delete | Flaga miękkiego usuwania (0/1) |
| o | Kolejność załączników (używana przez sortowanie drag&drop w adminie) |
**Używane w:** `Domain\Article\ArticleRepository::find()`, `Domain\Article\ArticleRepository::saveFilesOrder()`
## pp_units
Jednostki/slowniki (np. jednostki produktu).
| Kolumna | Opis |
|---------|------|
| id | PK |
**Używane w:** `Domain\Dictionaries\DictionariesRepository`, `admin\controls\ShopProduct`
## pp_units_langs
Tlumaczenia jednostek (per jezyk).
| Kolumna | Opis |
|---------|------|
| id | PK |
| unit_id | FK do pp_units |
| lang_id | ID jezyka (np. 'pl') |
| text | Nazwa jednostki |
**Używane w:** `Domain\Dictionaries\DictionariesRepository`
## pp_users
Uzytkownicy panelu administratora.
| Kolumna | Opis |
|---------|------|
| id | PK |
| login | Login / e-mail uzytkownika |
| password | Hash hasla (legacy: md5) |
| status | Status konta: 1 = aktywny, 0 = zablokowany |
| admin | Flaga dostepu do panelu admin |
| error_logged_count | Licznik nieudanych logowan |
| last_logged | Data ostatniego poprawnego logowania |
| last_error_logged | Data ostatniej nieudanej proby logowania |
| twofa_enabled | Czy wlaczone 2FA (0/1) |
| twofa_email | E-mail do wysylki kodu 2FA |
| twofa_code_hash | Hash aktualnego kodu 2FA |
| twofa_expires_at | Data waznosci kodu 2FA |
| twofa_sent_at | Data ostatniej wysylki kodu 2FA |
| twofa_failed_attempts | Liczba nieudanych prob 2FA |
**Uzywane w:** `Domain\User\UserRepository`, `admin\Controllers\UsersController`, `admin\factory\Users`
**Aktualizacja 2026-02-12:** uzycia `pp_users` sa prowadzone przez `Domain\\User\\UserRepository` (legacy `admin\\factory\\Users` usunieto).
## pp_langs
Jezyki panelu i frontendu.
| Kolumna | Opis |
|---------|------|
| id | PK (2-literowe ID jezyka, np. pl, en) |
| name | Nazwa jezyka |
| status | 1 = aktywny, 0 = nieaktywny |
| start | 1 = domyslny jezyk |
| o | Kolejnosc |
**Uzywane w:** `Domain\\Languages\\LanguagesRepository`, `admin\\Controllers\\LanguagesController`, `front\\factory\\Languages`
## pp_langs_translations
Slownik tlumaczen panelu/frontendu.
| Kolumna | Opis |
|---------|------|
| id | PK |
| text | Klucz/tekst bazowy |
| <lang_id> | Kolumny dynamiczne per jezyk (np. pl, en) |
**Uzywane w:** `Domain\\Languages\\LanguagesRepository`, `admin\\Controllers\\LanguagesController`, `front\\factory\\Languages`
**Aktualizacja 2026-02-12:** modul jezykow i tlumaczen (`pp_langs`, `pp_langs_translations`) obslugiwany przez `Domain\\Languages\\LanguagesRepository`.
## pp_layouts
Szablony layoutow (HTML/CSS/JS + flagi domyslne).
| Kolumna | Opis |
|---------|------|
| id | PK |
| name | Nazwa szablonu |
| html | Kod HTML |
| css | Kod CSS |
| js | Kod JS |
| m_html | Kod HTML mobilny |
| m_css | Kod CSS mobilny |
| m_js | Kod JS mobilny |
| status | Domyslny layout stron (0/1) |
| categories_default | Domyslny layout kategorii (0/1) |
**Uzywane w:** `Domain\\Layouts\\LayoutsRepository`, `admin\\Controllers\\LayoutsController`, `front\\factory\\Layouts`
## pp_layouts_pages
Przypisanie layoutow do stron CMS.
| Kolumna | Opis |
|---------|------|
| layout_id | FK do pp_layouts |
| page_id | FK do pp_pages |
**Uzywane w:** `Domain\\Layouts\\LayoutsRepository`, `front\\factory\\Layouts`
## pp_layouts_categories
Przypisanie layoutow do kategorii sklepu.
| Kolumna | Opis |
|---------|------|
| layout_id | FK do pp_layouts |
| category_id | FK do pp_shop_categories |
**Uzywane w:** `Domain\\Layouts\\LayoutsRepository`, `front\\factory\\Layouts`
**Aktualizacja 2026-02-12 (ver. 0.256):** modul `/admin/layouts` korzysta z `Domain\\Layouts\\LayoutsRepository` (DI kontroler + fasada legacy).
## pp_newsletter
Adresy e-mail zapisane do newslettera.
| Kolumna | Opis |
|---------|------|
| id | PK |
| email | Adres e-mail subskrybenta |
| hash | Hash potwierdzenia/wypisu |
| status | 1 = potwierdzony, 0 = oczekujacy |
**Uzywane w:** `Domain\\Newsletter\\NewsletterRepository`, `front\\Controllers\\NewsletterController`
## pp_newsletter_send
Kolejka wysylki newslettera.
| Kolumna | Opis |
|---------|------|
| id | PK |
| email | Adres docelowy |
| dates | Zakres dat artykulow (tekst) |
| id_template | FK do `pp_newsletter_templates` (NULL gdy brak szablonu) |
**Uzywane w:** `Domain\\Newsletter\\NewsletterRepository`
## pp_newsletter_templates
Szablony tresci e-maili (uzytkownik + administracyjne/systemowe).
| Kolumna | Opis |
|---------|------|
| id | PK |
| name | Nazwa/klucz szablonu |
| text | Tresc HTML szablonu |
| is_admin | 1 = szablon administracyjny/systemowy, 0 = szablon uzytkownika |
**Uzywane w:** `Domain\\Newsletter\\NewsletterRepository`, `admin\\Controllers\\NewsletterController`
**Aktualizacja 2026-02-12 (ver. 0.257):** modul `/admin/newsletter` korzysta z `Domain\\Newsletter\\NewsletterRepository` (DI kontroler + fasada legacy).
**Aktualizacja 2026-02-16 (ver. 0.279):** `front\\factory\\Newsletter` usunięta — logika przeniesiona do `NewsletterRepository`. Frontend korzysta z `front\\Controllers\\NewsletterController` (DI).
## pp_scontainers
Kontenery statyczne (modul /admin/scontainers).
| Kolumna | Opis |
|---------|------|
| id | PK |
| status | 1 = aktywny, 0 = nieaktywny |
| show_title | 1 = pokaz tytul, 0 = ukryj tytul |
**Uzywane w:** `Domain\Scontainers\ScontainersRepository`, `admin\Controllers\ScontainersController`, `front\factory\Scontainers`
## pp_scontainers_langs
Tlumaczenia kontenerow statycznych (per jezyk).
| Kolumna | Opis |
|---------|------|
| id | PK |
| container_id | FK do pp_scontainers |
| lang_id | ID jezyka (np. pl, en) |
| title | Tytul kontenera |
| text | Tresc HTML kontenera |
**Uzywane w:** `Domain\Scontainers\ScontainersRepository`, `front\factory\Scontainers`
**Aktualizacja 2026-02-12 (ver. 0.259):** modul `/admin/scontainers` korzysta z `Domain\Scontainers\ScontainersRepository` (DI kontroler + fasada legacy).
**Aktualizacja 2026-02-12 (ver. 0.260):** modul `/admin/articles_archive` korzysta z `Domain\Article\ArticleRepository` (`listArchivedForAdmin`, `restore`, `deletePermanently`) przez `admin\Controllers\ArticlesArchiveController`.
## pp_shop_attributes
Cechy produktu (modul `/admin/shop_attribute`).
| Kolumna | Opis |
|---------|------|
| id | PK |
| status | Status: 1 = aktywny, 0 = nieaktywny |
| type | Typ cechy: 0 = tekst, 1 = kolor, 2 = wzor |
| o | Kolejnosc wyswietlania |
**Uzywane w:** `Domain\Attribute\AttributeRepository`, `admin\Controllers\ShopAttributeController`, `admin\controls\ShopProduct`, `admin\factory\ShopProduct`
## pp_shop_attributes_langs
Tlumaczenia cech produktu (per jezyk).
| Kolumna | Opis |
|---------|------|
| id | PK |
| attribute_id | FK do pp_shop_attributes |
| lang_id | ID jezyka (np. pl, en) |
| name | Nazwa cechy |
**Uzywane w:** `Domain\Attribute\AttributeRepository`, `shop\ProductAttribute`
## pp_shop_attributes_values
Wartosci cech produktu.
| Kolumna | Opis |
|---------|------|
| id | PK |
| attribute_id | FK do pp_shop_attributes |
| is_default | Czy wartosc domyslna dla cechy (0/1) |
| impact_on_the_price | Wplyw na cene wariantu (NULL = brak) |
**Uzywane w:** `Domain\Attribute\AttributeRepository`, `admin\Controllers\ShopAttributeController`, `admin\factory\ShopProduct`
## pp_shop_attributes_values_langs
Tlumaczenia wartosci cech (per jezyk).
| Kolumna | Opis |
|---------|------|
| id | PK |
| value_id | FK do pp_shop_attributes_values |
| lang_id | ID jezyka (np. pl, en) |
| name | Nazwa wyswietlana |
| value | Wewnetrzna wartosc techniczna (opcjonalna) |
**Uzywane w:** `Domain\Attribute\AttributeRepository`, `shop\ProductAttribute`
## pp_shop_products_attributes
Powiazanie kombinacji produktow z wartosciami cech.
| Kolumna | Opis |
|---------|------|
| product_id | FK do pp_shop_products (kombinacja) |
| value_id | FK do pp_shop_attributes_values |
**Uzywane w:** `Domain\Attribute\AttributeRepository::refreshCombinationPricesForValue()`, `admin\controls\ShopProduct`, `admin\factory\ShopProduct`
**Aktualizacja 2026-02-14 (ver. 0.271):** modul `/admin/shop_attribute` korzysta z `Domain\Attribute\AttributeRepository` przez `admin\Controllers\ShopAttributeController`. Usunieto legacy klasy `admin\controls\ShopAttribute`, `admin\factory\ShopAttribute`, `admin\view\ShopAttribute`.
## pp_shop_coupon
Kody rabatowe sklepu (modul `/admin/shop_coupon`).
| Kolumna | Opis |
|---------|------|
| id | PK |
| name | Kod kuponu (UNIQUE) |
| status | Status: 1 = aktywny, 0 = nieaktywny |
| send | Czy kupon zostal wyslany (0/1) |
| used | Czy kupon zostal wykorzystany (0/1) |
| date_used | Data wykorzystania kuponu (NULL gdy brak) |
| used_count | Licznik uzyc kuponu |
| type | Typ kuponu (obecnie: 1 = rabat procentowy na koszyk) |
| amount | Wartosc kuponu (np. procent) |
| one_time | Czy kupon jednorazowy (0/1) |
| include_discounted_product | Czy obejmuje rowniez produkty przecenione (0/1) |
| categories | JSON z ID kategorii objetych kuponem (NULL = bez ograniczenia) |
**Uzywane w:** `Domain\Coupon\CouponRepository`, `admin\Controllers\ShopCouponController`, `front\Controllers\ShopCouponController`, `shop\Coupon`, `Domain\Order\OrderRepository`
**Aktualizacja 2026-02-13 (ver. 0.266):** modul `/admin/shop_coupon` korzysta z `Domain\Coupon\CouponRepository` przez `admin\Controllers\ShopCouponController`. Usunieto legacy klasy `admin\controls\ShopCoupon` i `admin\factory\ShopCoupon`.
## pp_shop_promotion
Promocje sklepu (modul `/admin/shop_promotion`).
| Kolumna | Opis |
|---------|------|
| id | PK |
| name | Nazwa promocji |
| status | Status: 1 = aktywna, 0 = nieaktywna |
| condition_type | Typ warunku promocji (slownik w `shop\Promotion::$condition_type`) |
| discount_type | Typ rabatu (slownik w `shop\Promotion::$discount_type`) |
| amount | Wartosc rabatu (np. procent) |
| date_from | Data startu promocji (NULL = aktywna od razu) |
| date_to | Data konca promocji (NULL = bez daty konca) |
| categories | JSON z ID kategorii grupy I |
| condition_categories | JSON z ID kategorii grupy II |
| include_coupon | Czy laczyc z kuponami rabatowymi (0/1) |
| include_product_promo | Czy uwzgledniac produkty przecenione (0/1) |
| min_product_count | Minimalna liczba produktow (dla wybranych warunkow) |
| price_cheapest_product | Cena najtanszego produktu (dla wybranych warunkow) |
**Uzywane w:** `Domain\Promotion\PromotionRepository`, `admin\Controllers\ShopPromotionController`, `shop\Promotion`, `front\factory\ShopPromotion`
**Aktualizacja 2026-02-13:** modul `/admin/shop_promotion` korzysta z `Domain\Promotion\PromotionRepository` przez `admin\Controllers\ShopPromotionController`. Usunieto legacy klasy `admin\controls\ShopPromotion` i `admin\factory\ShopPromotion`.
**Aktualizacja 2026-02-13 (ver. 0.265):** dodano obsluge `date_from` (repozytorium, formularz admin, lista admin, filtr aktywnych promocji na froncie) oraz poprawke zapisu edycji promocji po `id`.
## pp_shop_payment_methods
Metody platnosci sklepu (modul `/admin/shop_payment_method`).
| Kolumna | Opis |
|---------|------|
| id | PK |
| name | Nazwa metody platnosci |
| description | Opis metody platnosci (wyswietlany m.in. w checkout) |
| status | Status: 1 = aktywna, 0 = nieaktywna |
| apilo_payment_type_id | ID typu platnosci Apilo (NULL gdy brak mapowania) |
| min_order_amount | Minimalna kwota zamowienia (DECIMAL(10,2), NULL = brak limitu) |
| max_order_amount | Maksymalna kwota zamowienia (DECIMAL(10,2), NULL = brak limitu) |
| is_cod | Platnosc przy odbiorze: 1 = tak, 0 = nie (TINYINT DEFAULT 0) |
| sellasist_payment_type_id | DEPRECATED (integracja Sellasist usunieta w ver. 0.263) |
**Uzywane w:** `Domain\PaymentMethod\PaymentMethodRepository`, `admin\Controllers\ShopPaymentMethodController`, `front\factory\ShopPaymentMethod`, `shop\PaymentMethod`, `admin\controls\ShopTransport`, `cron.php`
**Aktualizacja 2026-03-12 (ver. 0.338):** dodano kolumne `is_cod` — flaga platnosci przy odbiorze, zastepuje hardkodowane `payment_id == 3` w `OrderRepository::createFromBasket()`.
**Aktualizacja 2026-02-14 (ver. 0.268):** modul `/admin/shop_payment_method` korzysta z `Domain\PaymentMethod\PaymentMethodRepository` przez `admin\Controllers\ShopPaymentMethodController`. Usunieto legacy klasy `admin\controls\ShopPaymentMethod`, `admin\factory\ShopPaymentMethod`, `admin\view\ShopPaymentMethod` oraz widok `admin/templates/shop-payment-method/view-list.php`.
**Aktualizacja 2026-02-22 (ver. 0.304):** dodano kolumny `min_order_amount` i `max_order_amount` — konfigurowalne limity kwotowe metod platnosci. Zastapiono hardcoded warunek PayPo (id=6, 40-1000 PLN) generycznym filtrowaniem na froncie.
## pp_shop_transports
Rodzaje transportu sklepu (modul `/admin/shop_transport`).
| Kolumna | Opis |
|---------|------|
| id | PK |
| name | Nazwa (systemowa, readonly) |
| name_visible | Nazwa widoczna dla klienta |
| description | Opis metody transportu |
| status | Status: 1 = aktywny, 0 = nieaktywny |
| cost | Koszt dostawy (PLN) |
| max_wp | Maksymalna waga paczki (NULL = bez limitu) |
| default | Domyslna forma dostawy (0/1) |
| delivery_free | Czy obsluguje darmowa dostawe (0/1) |
| apilo_carrier_account_id | ID konta przewoznika w Apilo (NULL gdy brak mapowania) |
| o | Kolejnosc wyswietlania |
**Uzywane w:** `Domain\Transport\TransportRepository`, `admin\Controllers\ShopTransportController`, `front\factory\ShopTransport`
## pp_shop_transport_payment_methods
Powiazanie metod transportu z metodami platnosci (tabela lacznikowa).
| Kolumna | Opis |
|---------|------|
| id_transport | FK do pp_shop_transports |
| id_payment_method | FK do pp_shop_payment_methods |
**Uzywane w:** `Domain\Transport\TransportRepository`, `Domain\PaymentMethod\PaymentMethodRepository::forTransport()`
**Aktualizacja 2026-02-14 (ver. 0.269):** modul `/admin/shop_transport` korzysta z `Domain\Transport\TransportRepository` przez `admin\Controllers\ShopTransportController`. Usunieto legacy klasy `admin\controls\ShopTransport`, `admin\view\ShopTransport` oraz widok `admin/templates/shop-transport/view-list.php`.
## pp_shop_apilo_settings
Ustawienia integracji Apilo (key-value).
| Kolumna | Opis |
|---------|------|
| id | PK |
| name | Klucz ustawienia (np. client-id, access-token) |
| value | Wartosc ustawienia |
**Uzywane w:** `Domain\Integrations\IntegrationsRepository`, `admin\Controllers\IntegrationsController`, `admin\factory\Integrations`
## pp_shop_shoppro_settings
Ustawienia integracji ShopPRO (key-value).
| Kolumna | Opis |
|---------|------|
| id | PK |
| name | Klucz ustawienia (np. domain, db_name) |
| value | Wartosc ustawienia |
**Uzywane w:** `Domain\Integrations\IntegrationsRepository`, `admin\Controllers\IntegrationsController`, `admin\factory\Integrations`
**Aktualizacja 2026-02-13:** modul `/admin/integrations/` korzysta z `Domain\Integrations\IntegrationsRepository` (DI kontroler + fasada legacy). Usunieto integracje Sellasist i Baselinker.
## pp_shop_statuses
Statusy zamowien sklepu (modul `/admin/shop_statuses`). Statusy sa predefiniowane - brak dodawania/usuwania, mozliwa edycja koloru i mapowania Apilo.
| Kolumna | Opis |
|---------|------|
| id | PK (zaczyna sie od 0!) |
| status | Nazwa statusu (read-only) |
| color | Kolor statusu (hex, np. #ff0000) |
| o | Kolejnosc wyswietlania |
| apilo_status_id | ID statusu w Apilo (NULL gdy brak mapowania) |
| baselinker_status_id | DEPRECATED (usuniety w ver. 0.263) |
| sellasist_status_id | DEPRECATED (usuniety w ver. 0.263) |
**Uzywane w:** `Domain\ShopStatus\ShopStatusRepository`, `admin\Controllers\ShopStatusesController`, `front\factory\ShopStatuses`, `shop\Order`, `cron.php`
**Aktualizacja 2026-02-14 (ver. 0.267):** modul `/admin/shop_statuses` korzysta z `Domain\ShopStatus\ShopStatusRepository` przez `admin\Controllers\ShopStatusesController`. Usunieto legacy klasy `admin\controls\ShopStatuses` i `admin\factory\ShopStatuses`. `front\factory\ShopStatuses` dziala jako fasada do repozytorium.
## pp_shop_product_sets
Komplety produktow (modul `/admin/shop_product_sets`).
| Kolumna | Opis |
|---------|------|
| id | PK |
| name | Nazwa kompletu |
| status | Status: 1 = aktywny, 0 = nieaktywny |
**Uzywane w:** `Domain\ProductSet\ProductSetRepository`, `admin\Controllers\ShopProductSetsController`, `shop\ProductSet`, `shop\Product`
## pp_shop_product_sets_products
Powiazanie kompletow z produktami (tabela lacznikowa).
| Kolumna | Opis |
|---------|------|
| id | PK |
| set_id | FK do pp_shop_product_sets |
| product_id | FK do pp_shop_products |
**Uzywane w:** `Domain\ProductSet\ProductSetRepository`, `shop\Product`, `front\factory\ShopProduct`, `admin\factory\ShopProduct`
**Aktualizacja 2026-02-15 (ver. 0.272):** modul `/admin/shop_product_sets` korzysta z `Domain\ProductSet\ProductSetRepository` przez `admin\Controllers\ShopProductSetsController`. Usunieto legacy klasy `admin\controls\ShopProductSets` i `admin\factory\ShopProductSet`. `shop\ProductSet` dziala jako fasada do repozytorium.
## pp_shop_producer
Producenci produktow (modul `/admin/shop_producer`).
| Kolumna | Opis |
|---------|------|
| id | PK |
| name | Nazwa producenta |
| status | Status: 1 = aktywny, 0 = nieaktywny |
| img | Sciezka do logo producenta (NULL gdy brak) |
**Uzywane w:** `Domain\Producer\ProducerRepository`, `admin\Controllers\ShopProducerController`, `front\Controllers\ShopProducerController`, `shop\Product`
## pp_shop_producer_lang
Tlumaczenia producentow (per jezyk). FK kaskadowe ON DELETE CASCADE.
| Kolumna | Opis |
|---------|------|
| id | PK |
| producer_id | FK do pp_shop_producer |
| lang_id | ID jezyka (np. pl, en) |
| description | Opis producenta (TEXT) |
| data | Dane producenta (TEXT, HTML) |
| meta_title | Meta title SEO (VARCHAR 255) |
**Uzywane w:** `Domain\Producer\ProducerRepository`, `shop\Product`
**Aktualizacja 2026-02-15 (ver. 0.273):** modul `/admin/shop_producer` korzysta z `Domain\Producer\ProducerRepository` przez `admin\Controllers\ShopProducerController`. Usunieto legacy `admin\controls\ShopProducer` i `admin\factory\ShopProducer`. `shop\Producer` dziala jako fasada do repozytorium.
**Aktualizacja 2026-02-17 (ver. 0.291):** frontend `/shop_producer/*` korzysta z `Domain\Producer\ProducerRepository` przez `front\Controllers\ShopProducerController`; usunięto legacy `front\controls\ShopProducer` i `shop\Producer`.
## pp_cron_jobs
Kolejka zadań cron z priorytetami i retry/backoff.
| Kolumna | Opis |
|---------|------|
| id | PK auto increment |
| job_type | Typ zadania (VARCHAR 50) — np. apilo_send_order, price_history |
| status | ENUM: pending, processing, completed, failed, cancelled |
| priority | TINYINT — niższy = ważniejszy (10=krytyczny, 50=wysoki, 100=normalny, 200=niski) |
| payload | JSON z danymi zadania (TEXT NULL) |
| result | JSON z wynikiem (TEXT NULL) |
| attempts | Liczba prób (SMALLINT) |
| max_attempts | Maksymalna liczba prób (SMALLINT, domyślnie 10) |
| last_error | Ostatni błąd (VARCHAR 500) |
| scheduled_at | Kiedy zadanie ma być uruchomione (DATETIME) |
| started_at | Kiedy rozpoczęto przetwarzanie (DATETIME NULL) |
| completed_at | Kiedy zakończono (DATETIME NULL) |
| created_at | Data utworzenia (DATETIME) |
| updated_at | Data ostatniej modyfikacji (DATETIME, ON UPDATE) |
**Indeksy:** idx_status_priority_scheduled (status, priority, scheduled_at), idx_job_type, idx_status
**Używane w:** `Domain\CronJob\CronJobRepository`, `Domain\CronJob\CronJobProcessor`
## pp_cron_schedules
Harmonogram cyklicznych zadań cron.
| Kolumna | Opis |
|---------|------|
| id | PK auto increment |
| job_type | Typ zadania (VARCHAR 50, UNIQUE) |
| interval_seconds | Interwał uruchomienia w sekundach |
| priority | Priorytet tworzonych zadań (TINYINT) |
| max_attempts | Maks. prób dla tworzonych zadań (SMALLINT) |
| payload | Opcjonalny payload JSON (TEXT NULL) |
| enabled | Czy harmonogram aktywny (TINYINT 1) |
| last_run_at | Ostatnie uruchomienie (DATETIME NULL) |
| next_run_at | Następne planowane uruchomienie (DATETIME NULL) |
| created_at | Data utworzenia (DATETIME) |
**Indeksy:** idx_enabled_next_run (enabled, next_run_at)
**Używane w:** `Domain\CronJob\CronJobRepository`, `Domain\CronJob\CronJobProcessor`
**Dodano w wersji 0.324.**
## pp_routes
Tabela tras URL — mapowanie wzorców URL (regex) na parametry GET. Zastępuje reguły `RewriteRule` w `.htaccess` dla wszystkich URL-i aplikacji: produktów, kategorii, stron, artykułów oraz systemowych (koszyk, logowanie, newsletter, itp.).
| Kolumna | Opis |
|---------|------|
| id | Klucz główny (AUTO_INCREMENT) |
| product_id | ID produktu (INT NULL) — wypełnione dla tras produktów |
| category_id | ID kategorii (INT NULL) — wypełnione dla tras kategorii |
| page_id | ID strony (INT NULL) — wypełnione dla tras stron |
| article_id | ID artykułu (INT NULL) — wypełnione dla tras artykułów |
| type | Typ trasy: NULL = encja (produkt/kategoria/strona/artykuł), `'system'` = trasa systemowa (koszyk, logowanie, newsletter, AJAX moduły, itp.) |
| lang_id | ID języka (0 dla tras systemowych niezwiązanych z językiem) |
| pattern | Wyrażenie regularne dopasowywane do REQUEST_URI |
| destination | Docelowy query string, np. `index.php?category=5&lang=1` |
**Mechanizm:** `index.php` ładuje wszystkie trasy (z cache Redis `pp_routes:all`) przed `checkUrlParams()`, dopasowuje `pattern` do ścieżki żądania i ustawia `$_GET` z `destination`. Obsługuje grupy przechwytujące (np. `$1` dla paginacji).
**Trasy systemowe:** Przy każdym wywołaniu `Helpers::htacces()` wszystkie rekordy z `type='system'` są usuwane i wstawiane na nowo (32 statycznych + dynamiczne trasy językowe i producentów). Zarządzane automatycznie — nie edytować ręcznie.
**Cache:** Redis klucz `pp_routes:all`, TTL 86400s. Invalidowany automatycznie przy każdym wywołaniu `Helpers::htacces()`.
**Używane w:** `index.php`, `Shared\Helpers\Helpers::htacces()`, `Domain\Product\ProductRepository`, `Domain\Category\CategoryRepository`, `Domain\Pages\PagesRepository`, `Domain\Article\ArticleRepository`
**Dodano w wersji 0.329. Kolumna `type` i trasy systemowe dodane w wersji 0.330.**

View File

@@ -1,178 +0,0 @@
# Form Edit System - Dokumentacja użycia
## Architektura
```
┌─────────────────────────────────────────────────────────────┐
│ Controller │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ edit() │ │ save() │ │
│ │ - buduje VM │ │ - walidacja │ │
│ │ - renderuje │ │ - zapis │ │
│ └────────┬────────┘ └─────────────────┘ │
└───────────┼─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ FormEditViewModel │
│ - title, formId, data, fields, tabs, actions │
│ - validationErrors, persist, languages │
└───────────┬─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ components/form-edit.php (szablon) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ FormFieldRenderer - renderuje każde pole │ │
│ │ ├─ input, select, textarea, switch │ │
│ │ ├─ date, datetime, editor, image │ │
│ │ └─ lang_section (zagnieżdżone pola) │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## Pliki systemu
| Plik | Opis |
|------|------|
| `autoload/admin/ViewModels/Forms/FormFieldType.php` | Stale typow pol |
| `autoload/admin/ViewModels/Forms/FormField.php` | Factory methods per typ |
| `autoload/admin/ViewModels/Forms/FormTab.php` | Zakladki |
| `autoload/admin/ViewModels/Forms/FormAction.php` | Akcje (zapisz, anuluj) |
| `autoload/admin/ViewModels/Forms/FormEditViewModel.php` | ViewModel formularza |
| `autoload/admin/Support/Forms/FormValidator.php` | Walidacja pol |
| `autoload/admin/Support/Forms/FormRequestHandler.php` | Obsluga POST + persist |
| `autoload/admin/Support/Forms/FormFieldRenderer.php` | Renderowanie HTML |
| `admin/templates/components/form-edit.php` | Uniwersalny szablon |
## Przykład użycia w kontrolerze
```php
use admin\ViewModels\Forms\FormEditViewModel;
use admin\ViewModels\Forms\FormField;
use admin\ViewModels\Forms\FormTab;
use admin\ViewModels\Forms\FormAction;
use admin\Support\Forms\FormRequestHandler;
class BannerController
{
public function edit(): string
{
$banner = $this->repository->find($id);
$languages = \admin\factory\Languages::languages_list();
$viewModel = new FormEditViewModel(
formId: 'banner-edit',
title: 'Edycja banera',
data: $banner,
tabs: [
new FormTab('settings', 'Ustawienia', 'fa-wrench'),
new FormTab('content', 'Zawartość', 'fa-file'),
],
fields: [
// Zakładka Ustawienia
FormField::text('name', [
'label' => 'Nazwa',
'tab' => 'settings',
'required' => true,
]),
FormField::switch('status', [
'label' => 'Aktywny',
'tab' => 'settings',
]),
FormField::date('date_start', [
'label' => 'Data rozpoczęcia',
'tab' => 'settings',
]),
// Sekcja językowa w zakładce Zawartość
FormField::langSection('translations', 'content', [
FormField::image('src', ['label' => 'Obraz']),
FormField::text('url', ['label' => 'Url']),
FormField::editor('text', ['label' => 'Treść']),
]),
],
actions: [
FormAction::save('/admin/banners/save', '/admin/banners'),
FormAction::cancel('/admin/banners'),
],
languages: $languages,
persist: true,
);
return \Tpl::view('components/form-edit', ['form' => $viewModel]);
}
public function save(): void
{
$formHandler = new FormRequestHandler();
$viewModel = $this->buildFormViewModel(); // jak w edit()
$result = $formHandler->handleSubmit($viewModel, $_POST);
if (!$result['success']) {
// Błędy walidacji - zapisane automatycznie do sesji
echo json_encode(['success' => false, 'errors' => $result['errors']]);
exit;
}
// Sukces - persist wyczyszczony automatycznie
$this->repository->save($result['data']);
echo json_encode(['success' => true]);
exit;
}
}
```
## Dostępne typy pól
| Typ | Metoda | Opcje |
|-----|--------|-------|
| `text` | `FormField::text(name, ['label' => '...', 'required' => true])` | placeholder, help |
| `number` | `FormField::number(name, [...])` | - |
| `email` | `FormField::email(name, [...])` | walidacja formatu |
| `password` | `FormField::password(name, [...])` | - |
| `date` | `FormField::date(name, [...])` | datetimepicker |
| `datetime` | `FormField::datetime(name, [...])` | datetimepicker z czasem |
| `switch` | `FormField::switch(name, [...])` | checked (bool) |
| `select` | `FormField::select(name, ['options' => [...]])` | options: [key => label] |
| `textarea` | `FormField::textarea(name, ['rows' => 4])` | rows |
| `editor` | `FormField::editor(name, ['toolbar' => 'MyTool'])` | CKEditor |
| `image` | `FormField::image(name, ['filemanager' => true])` | filemanager URL |
| `file` | `FormField::file(name, [...])` | filemanager |
| `hidden` | `FormField::hidden(name, value)` | - |
| `color` | `FormField::color(name, ['label' => '...'])` | HTML5 color picker + text input |
| `lang_section` | `FormField::langSection(name, 'tab', [fields])` | pola per język |
## Walidacja
Walidacja jest automatyczna na podstawie właściwości pól:
- `required` - pole wymagane
- `type` = `email` - walidacja formatu e-mail
- `type` = `number` - walidacja liczby
- `type` = `date` - walidacja formatu YYYY-MM-DD
Dla sekcji językowych walidacja jest powtarzana dla każdego aktywnego języka.
## Persist (zapamiętywanie danych)
Gdy `persist = true`:
1. Przy błędzie walidacji dane są zapisywane w `$_SESSION['form_persist'][$formId]`
2. Formularz automatycznie przywraca dane z sesji przy ponownym wyświetleniu
3. Po udanym zapisie sesja jest czyszczona automatycznie przez `FormRequestHandler`
## Przerabianie istniejących formularzy
1. **Kontroler** - zamień `view\Xxx::edit()` na `FormEditViewModel`
2. **Repository** - dostosuj `save()` do formatu z `FormRequestHandler` (lub dodaj wsparcie dla obu formatów)
3. **Szablon** - usuń stary szablon lub zostaw jako fallback
4. **Testy** - zaktualizuj testy jeśli zmienił się format danych
## Aktualizacja 2026-02-15 (ver. 0.275)
- Modul `ShopCategory` zostal zmigrowany do warstwy Domain + DI, ale formularz kategorii nadal korzysta z legacy `gridEdit`.
- W ramach migracji wydzielono skrypty UI do osobnych partiali `*-custom-script.php` (lista, browse, edycja, produkty), co upraszcza dalsze przepiecie formularza na `components/form-edit`.
- Po migracji `ShopCategory` kolejnym kandydatem do pelnej migracji formularza na Form Edit System pozostaje modul `Order` (zgodnie z `REFACTORING_PLAN.md`).
---
*Dokument aktualizowany: 2026-02-15*

View File

@@ -1,42 +0,0 @@
# Pamięć projektu shopPRO
Notatki i wnioski zebrane podczas pracy z kodem. Aktualizowane na bieżąco.
---
## Serwer produkcyjny
- PHP < 8.0 — unikać `match`, named arguments, union types, `str_contains()` itp.
- Zamiast `match` używać operatorów trójargumentowych (ternary) lub `if/else`
## Znane problemy / TODO
- `\Shared\Helpers\Helpers::send_email()` i `Shared\Email\Email::send()` — zduplikowana logika PHPMailer. Docelowo zunifikować w `Shared\Email\Email`
## Wzorce potwierdzone w projekcie
- Metody frontendowe (z cache Redis) dodawane do istniejących repozytoriów Domain — NIE tworzymy osobnych FrontendService/AdminService
- Klasy View (`front\Views\*`) są statyczne i bezstanowe — nie wymagają DI
- Kontrolery (`Controllers\*`) są instancyjne z DI przez konstruktor
- Autoloader obsługuje dwa formaty plików: `class.X.php` (legacy) i `X.php` (nowy) — oba działają bez zmian w autoloaderze
- Nowe katalogi z dużej litery: `Views/`, `Controllers/` (legacy: `view/`, `controls/`, `factory/`)
## Medoo ORM — pułapki
- `$mdb->delete()` przyjmuje 2 argumenty (tabela, warunek), NIE 3 — wielokrotnie powodowało bugi (np. `newsletter_unsubscribe`)
- `$mdb->get()` zwraca `null` gdy brak rekordu, NIE `false`
- Przy `$mdb->insert()` sprawdzać `$mdb->id()` aby potwierdzić sukces
## Redis cache — konwencje
- TTL domyślnie 86400 (24h)
- Klucze produktów: `shop\product:{id}:{lang}:{permutation_hash}`
- Wzorzec czyszczenia: `CacheHandler::deletePattern("shop\\product:{$id}:*")`
- Dane w cache są serializowane — wymagają `unserialize()` po `get()`
## Aktualizacje klienckie
- Pliki `*.md` NIGDY nie trafiają do ZIP aktualizacji
- `updates/changelog.php` to plik serwisowy repozytorium, nie runtime klienta
- Główny `.htaccess` wdrażany osobno, poza ZIP aktualizacji
- W archiwum ZIP NIE powinno być folderu z nazwą wersji — struktura zaczyna się od katalogów projektu

View File

@@ -1,150 +0,0 @@
# PAUL — Workflow dla shopPRO
[PAUL](https://github.com/ChristopherKahler/paul) to zestaw komend dla Claude Code, który strukturyzuje pracę nad projektem: planowanie → implementacja → weryfikacja. Działa przez slash-komendy w czacie z Claude.
---
## Jednorazowa inicjalizacja
```
/paul:init
```
Uruchom raz — tworzy plik `.paul/` z konfiguracją projektu (milestones, fazy). Jeśli już zainicjalizowany, pomijasz ten krok.
---
## Nowa funkcja (feature)
### 1. Omów wizję
```
/paul:discuss
```
Claude zadaje pytania, żeby doprecyzować, co dokładnie chcesz zbudować — wynik to jasna definicja przed planowaniem.
### 2. Zbadaj opcje techniczne (opcjonalnie)
```
/paul:discover
```
Przydatne gdy nie jesteś pewny jak zaimplementować coś technicznie — Claude przegląda kod i proponuje podejścia.
### 3. Zaplanuj implementację
```
/paul:plan
```
Tworzy szczegółowy plan z krokami, plikami do zmiany, kolejnością. Zatwierdź plan zanim ruszysz dalej.
### 4. Wykonaj plan
```
/paul:apply
```
Claude implementuje plan krok po kroku, z commitami na każdą fazę.
### 5. Zweryfikuj (UAT)
```
/paul:verify
```
Claude generuje checklistę testów manualnych — punkt po punkcie sprawdzasz czy funkcja działa.
---
## Poprawka błędu (bug fix)
```
/paul:plan-fix
```
Dedykowany flow dla bugów: opisujesz problem → Claude analizuje kod → tworzy plan naprawy → `/paul:apply` wykonuje.
Krótszy wariant dla prostych bugów — możesz też po prostu opisać błąd i użyć `/paul:plan``/paul:apply`.
---
## Zarządzanie sesjami
| Komenda | Kiedy |
|---------|-------|
| `/paul:progress` | Sprawdź gdzie jesteś, co dalej |
| `/paul:pause` | Kończysz sesję — tworzy plik handoff |
| `/paul:resume` | Zaczynasz nową sesję — wczytuje kontekst |
| `/paul:handoff` | Generuje dokument przekazania pracy |
---
## Milestones (większe wdrożenia)
Gdy planujesz większą wersję (np. nowy moduł, refactor):
```
/paul:milestone # Tworzysz nowy milestone
/paul:add-phase # Dodajesz fazę do milestone
/paul:complete-milestone # Zamykasz po wdrożeniu
```
---
## Czy PAUL może przejrzeć projekt pod kątem błędów?
Tak, częściowo. Najlepsze podejście:
### Mapowanie kodu
```
/paul:map-codebase
```
Generuje analizę architektury — wyłapuje niespójności, martwy kod, problematyczne zależności.
### Research konkretnego obszaru
```
/paul:research
```
Np. _"Sprawdź czy walidacja zamówień w OrderRepository jest kompletna"_ — Claude przegląda kod i raportuje problemy.
### Ograniczenia
PAUL nie jest narzędziem do statycznej analizy kodu (jak PHPStan czy psalm). Do systematycznego przeglądu błędów lepiej połączyć:
- **PAUL** (`/paul:map-codebase` + `/paul:research`) — problemy architektoniczne, logika biznesowa
- **Testy** (`./test.ps1`) — regresje, złe zachowanie metod
- **PHPStan** (jeśli dodany do projektu) — typy, niezdefiniowane zmienne
---
## Typowy dzień pracy w shopPRO
```
# Rano — wznów kontekst
/paul:resume
# W trakcie — sprawdź co dalej
/paul:progress
# Nowa funkcja lub bug
/paul:discuss → /paul:plan → /paul:apply → /paul:verify
# Na koniec dnia
/paul:pause
```
---
## Dobre praktyki
- Zawsze zatwierdzaj plan (`/paul:plan`) zanim uruchomisz `/paul:apply` — łatwiej zmienić plan niż cofnąć kod
- Po każdym `/paul:apply` odpal testy: `./test.ps1`
- Używaj `/paul:verify` przed mergem — generuje checklistę zamiast zgadywać co przetestować
- Przy bugach najpierw opisz symptom dokładnie, Claude lepiej planuje naprawę mając konkretny przypadek

View File

@@ -1,190 +0,0 @@
# Struktura Projektu shopPRO
Aktualna architektura po zakonczonej migracji na Domain-Driven Design + Dependency Injection.
## Warstwa domenowa (`autoload/Domain/`)
Kazdy modul zawiera Repository (i opcjonalnie dodatkowe klasy). Konstruktor DI z `$db` (Medoo). Metody sluza zarowno adminowi, jak i frontendowi (wspolna warstwa).
| Modul | Klasy | Uwagi |
|-------|-------|-------|
| Article | ArticleRepository | blog, aktualnosci, galerie, pliki |
| Attribute | AttributeRepository | cechy produktow + wartosci |
| Banner | BannerRepository | banery glowne + boczne, Redis cache |
| Basket | BasketCalculator | summary, count, walidacja stanow |
| Cache | CacheRepository | czyszczenie cache z poziomu admin |
| Category | CategoryRepository | drzewa kategorii, produkty w kategorii, Redis cache |
| Client | ClientRepository | CRUD, auth, adresy, zamowienia |
| Coupon | CouponRepository | kupony rabatowe, walidacja, uzycie |
| CronJob | CronJobType, CronJobRepository, CronJobProcessor | kolejka zadan cron (DB), priorytety, retry/backoff, harmonogram |
| Dashboard | DashboardRepository | statystyki admin, Redis cache |
| Dictionaries | DictionariesRepository | slowniki admin |
| Integrations | IntegrationsRepository | Apilo sync, ustawienia |
| Languages | LanguagesRepository | jezyki, tlumaczenia |
| Layouts | LayoutsRepository | layouty stron, 3-level fallback |
| Newsletter | NewsletterRepository, NewsletterPreviewRenderer | subskrypcje, szablony, kolejka wysylki |
| Order | OrderRepository, OrderAdminService | CRUD, Apilo sync, webhooki platnosci, kolejka retry |
| Pages | PagesRepository | strony, menu, drzewa stron |
| PaymentMethod | PaymentMethodRepository | metody platnosci, mapowanie Apilo |
| Producer | ProducerRepository | producenci |
| Product | ProductRepository | CRUD, cache, kombinacje, zdjecia, Google Feed XML |
| ProductSet | ProductSetRepository | zestawy produktow |
| Promotion | PromotionRepository | promocje, 5 typow applyType*, silnik dopasowania |
| Scontainers | ScontainersRepository | kontenery sidebaru |
| Settings | SettingsRepository | ustawienia sklepu |
| ShopStatus | ShopStatusRepository | statusy zamowien, mapowanie Apilo |
| Transport | TransportRepository | transport, koszty, powiazanie z platnosci |
| Update | UpdateRepository | aktualizacje, migracje SQL |
| User | UserRepository | uzytkownicy admin, 2FA, logowanie |
## Warstwa admin (`autoload/admin/`)
### Router: `admin\App`
- `getControllerFactories()` — mapa kontrolerow z DI wiring
- Brak fallbacku na legacy — wszystkie moduly na nowych kontrolerach
### Kontrolery (`admin\Controllers\`) — 28 kontrolerow
ArticlesArchive, Articles, Banner, Dashboard, Dictionaries, Filemanager, Integrations, Languages, Layouts, Newsletter, Pages, ProductArchive, Scontainers, Settings, ShopAttribute, ShopCategory, ShopClients, ShopCoupon, ShopOrder, ShopPaymentMethod, ShopProducer, ShopProduct, ShopProductSets, ShopPromotion, ShopStatuses, ShopTransport, Update, Users
### Support
- `admin\Support\TableListRequestFactory` — paginacja/sortowanie tabel
- `admin\Support\Forms\FormRequestHandler` — obsluga formularzy (persist przy bledach)
- `admin\Support\Forms\FormFieldRenderer` — renderowanie pol formularzy
### ViewModels
- `admin\ViewModels\Forms\` — FormEditViewModel, FormField, FormTab, FormAction, FormFieldType
- `admin\ViewModels\Common\PaginatedTableViewModel`
### Walidacja
- `admin\Validation\FormValidator` — reguly per pole, sekcje jezykowe
## Warstwa frontend (`autoload/front/`)
### Router: `front\App`
- `route()`, `checkUrlParams()`, `getControllerFactories()`
### Layout Engine: `front\LayoutEngine`
- `show()` — zamiana tagow szablonowych (kategorie, produkty, menu, banery, artykuly, kontenery, meta)
- `contact()`, `cookieInformation()`
### Kontrolery (`front\Controllers\`) — 8 kontrolerow
Newsletter, Search, ShopBasket, ShopClient, ShopCoupon, ShopOrder, ShopProducer, ShopProduct
### Widoki (`front\Views\`) — 11 klas statycznych
Articles, Banners, Languages, Menu, Newsletter, Scontainers, ShopCategory, ShopClient, ShopPaymentMethod, ShopProduct, ShopSearch
## Warstwa API (`autoload/api/`)
REST API dla ordersPRO. Entry point: `api.php`. Stateless (bez sesji), autentykacja przez `X-Api-Key` header.
### Router: `api\ApiRouter`
- `handle()` — autentykacja → routing → dispatch
- Helpery statyczne: `sendSuccess()`, `sendError()`, `getJsonBody()`, `requireMethod()`
### Kontrolery (`api\Controllers\`)
- `OrdersApiController` — lista, szczegoly, zmiana statusu, platnosc (5 akcji)
- `ProductsApiController` — lista, szczegoly, tworzenie, aktualizacja produktow (4 akcje)
- `DictionariesApiController` — statusy, transporty, metody platnosci (3 akcje)
- `CategoriesApiController` — lista aktywnych kategorii (1 akcja)
Dokumentacja: `docs/API.md`
## Warstwa wspoldzielona (`autoload/Shared/`)
| Klasa | Opis |
|-------|------|
| `Shared\Cache\CacheHandler` | Redis cache: get/set/delete/deletePattern |
| `Shared\Cache\RedisConnection` | Singleton polaczenia Redis |
| `Shared\Email\Email` | Wrapper PHPMailer |
| `Shared\Helpers\Helpers` | SEO, email, cache clearing, shortPrice, utility |
| `Shared\Html\Html` | Helpery HTML |
| `Shared\Image\ImageManipulator` | Obrobka obrazow GD |
| `Shared\Tpl\Tpl` | Silnik szablonow: render(), set() |
## Cache Redis
### Klucze
```
shop\product:{id}:{lang}:{permutation_hash} — dane produktu (TTL 24h)
ProductRepository::getProductPermutationQuantityOptions:v2:{id}:{perm} — ilosc + komunikaty
ProductRepository::productSetsWhenAddToBasket:{id} — zestawy "kupowane razem"
```
### Konwencje
- TTL domyslnie 86400 (24h)
- Dane serializowane — `unserialize()` po `get()`
- Czyszczenie: `CacheHandler::deletePattern("shop\\product:{$id}:*")`
- Czyszczenie z poziomu admin: `Shared\Helpers\Helpers::clear_product_cache($id)`
- Przycisk "Wyczysc cache" w admin: `SettingsController::clearCacheAjax()``flushAll()` Redis + `temp/` + `thumbs/`
## Entry pointy
| Plik | Rola |
|------|------|
| `index.php` | Frontend — autoload, sesja, DB, routing (`front\App`), layout (`front\LayoutEngine`), DOM post-processing |
| `ajax.php` | Frontend AJAX — koszyk, transport, kontakt |
| `api.php` | REST API (ordersPRO + Ekomi CSV) — router: `\api\ApiRouter`, kontrolery: `\api\Controllers\` |
| `admin/index.php` | Admin — autoload, sesja, DB, routing (`admin\App`) |
| `admin/ajax.php` | Admin AJAX |
| `cron.php` | CRON: Apilo sync (ceny/stany co 10min, cennik co 1h, retry queue) |
| `cron-turstmate.php` | TrustMate integracja |
| `cron/cron-xml.php` | Google Feed XML |
| `download.php` | Pobieranie plikow |
### Autoloader
Kazdy entry point rejestruje `__autoload_my_classes()`:
1. Probuje `autoload/{namespace}/class.{ClassName}.php` (legacy format)
2. Probuje `autoload/{namespace}/{ClassName}.php` (PSR-4 format)
### Routing frontend (index.php)
Przed `front\App::route()`:
1. Sprawdza tabele `pp_redirects` → 301 redirect
2. Sprawdza tabele `pp_routes` → regex pattern → destination
### Newsletter queue
`index.php` wywoluje `$newsletterRepo->sendQueued()` na koncu kazdego requestu frontendowego (limit 1 mail/request).
## Integracje zewnetrzne
### Apilo (cron.php)
- Synchronizacja cen/stanow produktow (co 10 min)
- Synchronizacja cennika (co 1h)
- Kolejka retry: `temp/apilo-sync-queue.json``OrderAdminService::processApiloSyncQueue()`
- Mapowanie statusow i platnosci przez tabele `pp_shop_statuses` i `pp_shop_payment_methods`
### Webhooki platnosci (front\Controllers\ShopOrderController)
- tPay, Przelewy24, Hotpay — ujednolicone: `set_as_paid` + `update_status`
## Biblioteki (`libraries/`)
- `medoo/medoo.php` — Medoo ORM (`$mdb`)
- `rb.php` — RedBeanPHP ORM (`\R::`, `$pdo`)
- `phpmailer/` — PHPMailer
## Wzorce architektoniczne
### DI zamiast global
```php
// Kontroler wiring (w admin\App lub front\App)
$repo = new \Domain\Example\ExampleRepository($mdb);
$controller = new \admin\Controllers\ExampleController($repo);
```
### Wspolna warstwa Domain
Metody frontendowe (z Redis cache) dodawane do istniejacych repozytoriow — NIE tworzymy osobnych FrontendService/AdminService.
### Klasy View — statyczne, bezstanowe
`front\Views\*` — nie wymagaja DI. Czyste funkcje: dane wchodza, HTML wychodzi.
### Kontrolery — instancyjne z DI
`Controllers\*` — repozytoria wstrzykiwane przez konstruktor.
### Nazewnictwo plikow
- Nowe: `ClassName.php`
- Legacy (pozostalosci): `class.ClassName.php`
- Autoloader obsluguje oba formaty
### Nazewnictwo katalogow
- Nowe: z duzej litery (`Views/`, `Controllers/`)
- Namespace `\admin\` z malej (bo katalog `admin/` jest z malej na serwerze Linux)
- NIE uzywac `\Admin\` (duze A)

View File

@@ -1,131 +0,0 @@
# Testowanie shopPRO
## Szybki start
```bash
# Pelny suite (PowerShell — rekomendowane)
./test.ps1
# Konkretny plik
./test.ps1 tests/Unit/Domain/Product/ProductRepositoryTest.php
# Konkretny test
./test.ps1 --filter testGetQuantityReturnsCorrectValue
# Alternatywne
composer test # standard
./test.bat # testdox (czytelna lista)
./test-simple.bat # kropki
./test-debug.bat # debug
./test.sh # Git Bash
```
## Aktualny stan
```text
OK (817 tests, 2271 assertions)
```
Zweryfikowano: 2026-03-12 (ver. 0.337)
## Konfiguracja
- **PHPUnit 9.6** via `phpunit.phar`
- **Bootstrap:** `tests/bootstrap.php`
- **Config:** `phpunit.xml`
## Struktura testow
```
tests/
|-- bootstrap.php
|-- stubs/
| |-- CacheHandler.php (inline w bootstrap)
| |-- Helpers.php (Shared\Helpers\Helpers stub)
| `-- ShopProduct.php (shop\Product stub)
|-- Unit/
| |-- Domain/
| | |-- Article/ArticleRepositoryTest.php
| | |-- Attribute/AttributeRepositoryTest.php
| | |-- Banner/BannerRepositoryTest.php
| | |-- Basket/BasketCalculatorTest.php
| | |-- Cache/CacheRepositoryTest.php
| | |-- Category/CategoryRepositoryTest.php
| | |-- Coupon/CouponRepositoryTest.php
| | |-- CronJob/CronJobTypeTest.php
| | |-- CronJob/CronJobRepositoryTest.php
| | |-- CronJob/CronJobProcessorTest.php
| | |-- Dictionaries/DictionariesRepositoryTest.php
| | |-- Integrations/IntegrationsRepositoryTest.php
| | |-- Languages/LanguagesRepositoryTest.php
| | |-- Layouts/LayoutsRepositoryTest.php
| | |-- Newsletter/NewsletterRepositoryTest.php
| | |-- Pages/PagesRepositoryTest.php
| | |-- PaymentMethod/PaymentMethodRepositoryTest.php
| | |-- Producer/ProducerRepositoryTest.php
| | |-- Product/ProductRepositoryTest.php
| | |-- ProductSet/ProductSetRepositoryTest.php
| | |-- Promotion/PromotionRepositoryTest.php
| | |-- Settings/SettingsRepositoryTest.php
| | |-- ShopStatus/ShopStatusRepositoryTest.php
| | |-- Transport/TransportRepositoryTest.php
| | |-- Update/UpdateRepositoryTest.php
| | `-- User/UserRepositoryTest.php
| |-- Shared/
| | `-- Security/
| | `-- CsrfTokenTest.php
| `-- admin/
| `-- Controllers/
| |-- ArticlesControllerTest.php
| |-- DictionariesControllerTest.php
| |-- IntegrationsControllerTest.php
| |-- ProductArchiveControllerTest.php
| |-- SettingsControllerTest.php
| |-- ShopAttributeControllerTest.php
| |-- ShopCategoryControllerTest.php
| |-- ShopCouponControllerTest.php
| |-- ShopPaymentMethodControllerTest.php
| |-- ShopProducerControllerTest.php
| |-- ShopProductControllerTest.php
| |-- ShopProductSetsControllerTest.php
| |-- ShopPromotionControllerTest.php
| |-- ShopStatusesControllerTest.php
| |-- ShopTransportControllerTest.php
| `-- UsersControllerTest.php
| |-- front/Controllers/
| | `-- ShopBasketControllerTest.php
| `-- api/
| |-- ApiRouterTest.php
| `-- Controllers/
| |-- OrdersApiControllerTest.php
| |-- ProductsApiControllerTest.php
| `-- DictionariesApiControllerTest.php
`-- Integration/ (puste — zarezerwowane)
```
## Dodawanie nowych testow
1. Plik w `tests/Unit/Domain/<Module>/<Class>Test.php`, `tests/Unit/admin/Controllers/<Class>Test.php` lub `tests/Unit/api/Controllers/<Class>Test.php`.
2. Rozszerz `PHPUnit\Framework\TestCase`.
3. Nazwy metod zaczynaj od `test`.
4. Wzorzec AAA: Arrange, Act, Assert.
## Mockowanie Medoo
```php
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('get')->willReturn(42);
$repo = new ProductRepository($mockDb);
$value = $repo->getQuantity(123);
$this->assertEquals(42, $value);
```
## Bootstrap — stuby
`tests/bootstrap.php` rejestruje autoloader i definiuje stuby:
- `Redis`, `RedisConnection` — klasy Redis (aby nie wymagac rozszerzenia)
- `Shared\Cache\CacheHandler` — inline stub z `get()`/`set()`/`exists()`/`delete()`/`deletePattern()`
- `Shared\Helpers\Helpers` — z `tests/stubs/Helpers.php`
- `shop\Product` — z `tests/stubs/ShopProduct.php`

View File

@@ -1,76 +0,0 @@
3. Dodać uwierzytelnienie dwuskładnikowe za pomocą aplikacji.
4. Dodać zarządzanie uprawnieniami na poziomie urzytkownika, na razie uprawnienia do poszczególnych modułów.
naprawić działanie newslettera i zapis do bazy newslettera
program lojalnościowy
proponowane produkty w koszyku
Do zamówień w statusie: realizowane lub oczekuje na wpłatę. Opcja tylko dla zarejestrowanych klientów. https://royal-stone.pl/pl/order1.html
Dodać możliwość ustawienia limitu znaków w wiadomościach do produktu
8. [] Przerobić analitykę Google Analytics i Google ADS
9. [x] Rozważyć integrację SonarQube (statyczna analiza kodu PHP — bugi, security, code smells). Community Edition darmowy, self-hosted. Wymaga serwera + MCP server w Claude Code.
## SonarQube — 0.340 (2026-03-15)
### Bugs
- [ ] [MAJOR] cron.php:192 — Review the data-flow - use of uninitialized value (php:S836)
- [ ] [MAJOR] cron.php:561 — Review the data-flow - use of uninitialized value (php:S836)
- [ ] [MAJOR] cron.php:590 — Review the data-flow - use of uninitialized value (php:S836)
- [ ] [MAJOR] cron.php:643 — Review the data-flow - use of uninitialized value (php:S836)
### Code Smells — CRITICAL
- [ ] [CRITICAL] autoload/Domain/Integrations/ApiloRepository.php:35 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] autoload/Domain/Integrations/ApiloRepository.php:66 — Define a constant instead of duplicating "Accept: application/json" 5 times (php:S1192)
- [ ] [CRITICAL] autoload/Domain/Integrations/ApiloRepository.php:77 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] autoload/Domain/Integrations/ApiloRepository.php:159 — Define a constant instead of duplicating "Y-m-d H:i:s" 3 times (php:S1192)
- [ ] [CRITICAL] autoload/Domain/Integrations/ApiloRepository.php:239 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] autoload/Domain/Integrations/ApiloRepository.php:309 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] autoload/Domain/Integrations/ApiloRepository.php:315 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] autoload/Domain/Integrations/ApiloRepository.php:339 — Define a constant instead of duplicating "Authorization: Bearer " 3 times (php:S1192)
- [ ] [CRITICAL] autoload/Domain/Integrations/ApiloRepository.php:359 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] autoload/Domain/Integrations/ApiloRepository.php:400 — Refactor this function to reduce its Cognitive Complexity (php:S3776)
- [ ] [CRITICAL] autoload/front/Controllers/ShopBasketController.php:499 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] autoload/front/Controllers/ShopBasketController.php:502 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] autoload/api/Controllers/ProductsApiController.php:396 — Refactor this function to reduce its Cognitive Complexity from 83 to 15 (php:S3776)
- [ ] [CRITICAL] autoload/Shared/Helpers/Helpers.php:408 — Refactor this function to reduce its Cognitive Complexity from 165 to 15 (php:S3776)
- [ ] [CRITICAL] autoload/Shared/Helpers/Helpers.php:520 — Define a constant instead of duplicating "/([0-9]+)$" 3 times (php:S1192)
- [ ] [CRITICAL] autoload/Shared/Helpers/Helpers.php:607 — Define a constant instead of duplicating " Order Deny,Allow" 3 times (php:S1192)
- [ ] [CRITICAL] autoload/Shared/Helpers/Helpers.php:650 — Define a constant instead of duplicating "&lang=" 7 times (php:S1192)
- [ ] [CRITICAL] cron.php:200 — Define a constant instead of duplicating "Y-m-d H:i:s" 7 times (php:S1192)
- [ ] [CRITICAL] cron.php:200 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] cron.php:203 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] cron.php:418 — Define a constant instead of duplicating "Authorization: Bearer " 5 times (php:S1192)
- [ ] [CRITICAL] cron.php:419 — Define a constant instead of duplicating "Accept: application/json" 5 times (php:S1192)
- [ ] [CRITICAL] cron.php:526 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] cron.php:529 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] cron.php:531 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] cron.php:533 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] cron.php:542 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] cron.php:545 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] cron.php:547 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] cron.php:555 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] cron.php:559 — Add curly braces around nested statement(s) (php:S121)
### Code Smells — MAJOR
- [ ] [MAJOR] autoload/Domain/Integrations/ApiloRepository.php:130 — Method has 4 returns, max 3 allowed (php:S1142)
- [ ] [MAJOR] autoload/Domain/Integrations/ApiloRepository.php:233 — Method has 5 returns, max 3 allowed (php:S1142)
- [ ] [MAJOR] autoload/Domain/Integrations/ApiloRepository.php:307 — Method has 7 returns, max 3 allowed (php:S1142)
- [ ] [MAJOR] autoload/Domain/Integrations/ApiloRepository.php:400 — Method has 8 returns, max 3 allowed (php:S1142)
- [ ] [MAJOR] autoload/Domain/Integrations/ApiloRepository.php:449 — Method has 4 returns, max 3 allowed (php:S1142)
- [ ] [MAJOR] autoload/Domain/Integrations/ApiloRepository.php:481 — Method has 4 returns, max 3 allowed (php:S1142)
- [ ] [MAJOR] autoload/Domain/Integrations/ApiloRepository.php:513 — Method has 4 returns, max 3 allowed (php:S1142)
- [ ] [MAJOR] autoload/front/Controllers/ShopBasketController.php:493 — Method has 4 returns, max 3 allowed (php:S1142)
- [ ] [MAJOR] autoload/Domain/Order/OrderAdminService.php:673 — Method has 4 returns, max 3 allowed (php:S1142)
- [ ] [MAJOR] autoload/Domain/Order/OrderAdminService.php:740 — Method has 4 returns, max 3 allowed (php:S1142)
### Code Smells — MINOR
- [ ] [MINOR] autoload/Domain/Order/OrderRepository.php — Add a new line at the end of file (php:S113)
- [ ] [MINOR] admin/templates/site/unlogged-layout.php — Add a new line at the end of file (php:S113)
- [ ] [MINOR] admin/templates/users/user-2fa.php — Add a new line at the end of file (php:S113)
- [ ] [MINOR] autoload/admin/Controllers/ProductArchiveController.php:196 — Rename function "bulk_delete_permanent" to match camelCase (php:S100)
- [ ] [MINOR] autoload/api/ApiRouter.php:107 — Remove unused "$db" local variable (php:S1481)
- [ ] [MINOR] cron.php:198 — Remove unused "$orderAdminService" local variable (php:S1481)
- [ ] [MINOR] cron.php:524 — Remove unused "$mdb" local variable (php:S1481)
- [ ] [MINOR] cron.php:539 — Remove unused "$mdb" local variable (php:S1481)

View File

@@ -1,73 +0,0 @@
# Instrukcja tworzenia aktualizacji shopPRO
## Nowy sposób (od v0.301) — automatyczny build script
### Wymagania
- Git z tagami wersji (np. `v0.299`, `v0.300`)
- PowerShell
### Workflow
```
1. Pracuj normalnie: commit, push, commit, push...
2. Gdy wersja gotowa:
→ git tag v0.XXX
→ ./build-update.ps1 -ToTag v0.XXX -ChangelogEntry "NEW - opis"
3. Upload plików z updates/0.XX/ na serwer aktualizacji
```
### Użycie build-update.ps1
```powershell
# Podgląd zmian (bez tworzenia plików)
./build-update.ps1 -ToTag v0.301 -DryRun
# Budowanie paczki (auto-detect poprzedniego tagu)
./build-update.ps1 -ToTag v0.301 -ChangelogEntry "NEW - opis zmiany"
# Z jawnym tagiem źródłowym
./build-update.ps1 -FromTag v0.300 -ToTag v0.301 -ChangelogEntry "NEW - opis"
```
### Co robi skrypt automatycznie
1. `git diff --name-status` między tagami → listy dodanych/zmodyfikowanych/usuniętych plików
2. Filtrowanie przez `.updateignore` (pliki deweloperskie, konfiguracyjne itp.)
3. Kopiowanie plików do temp, tworzenie ZIP
4. SHA256 checksum ZIP-a
5. Generowanie `ver_X.XXX_manifest.json`
6. Generowanie legacy `_sql.txt` i `_files.txt` (okres przejściowy)
7. Aktualizacja `versions.php` i `changelog.php`
8. Cleanup
### Pliki wynikowe
- `updates/0.XX/ver_X.XXX.zip` — paczka z plikami
- `updates/0.XX/ver_X.XXX_manifest.json` — manifest z checksumem, listą zmian, SQL
- `updates/0.XX/ver_X.XXX_sql.txt` — legacy SQL (okres przejściowy)
- `updates/0.XX/ver_X.XXX_files.txt` — legacy lista plików do usunięcia (okres przejściowy)
### Migracje SQL
Pliki SQL umieszczaj w `migrations/{version}.sql` (np. `migrations/0.301.sql`).
Build script automatycznie je wczyta i umieści w manifeście + legacy `_sql.txt`.
### Format manifestu
```json
{
"version": "0.301",
"date": "2026-02-22",
"checksum_zip": "sha256:abc123...",
"files": {
"modified": ["autoload/Domain/Order/OrderRepository.php"],
"added": ["autoload/Domain/Order/NewHelper.php"],
"deleted": ["autoload/shop/OldClass.php"]
},
"directories_deleted": [],
"sql": ["ALTER TABLE pp_x ADD COLUMN y INT DEFAULT 0"],
"changelog": "NEW - opis zmiany"
}
```
### .updateignore
Plik w katalogu głównym projektu, wzorce plików wykluczonych z paczek (jak `.gitignore`).
### INFO
pamiętaj że push czasem zwraca błąd autoryzacji, wtedy spróbuj ponownie

81
temp/diagnose_apilo.php Normal file
View File

@@ -0,0 +1,81 @@
<?php
/**
* Diagnostic script for Apilo integration on the instance DB
* Temporary — delete after diagnosis
*/
$db_host = 'host700513.hostido.net.pl';
$db_user = 'host700513_pomysloweprezenty-pl';
$db_pass = 'QBVbveHAzR78UN8pc7Um';
$db_name = 'host700513_pomysloweprezenty-pl';
$pdo = new PDO("mysql:host=$db_host;dbname=$db_name;charset=utf8", $db_user, $db_pass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "=== 1. APILO SETTINGS ===\n";
$stmt = $pdo->query("SELECT name, LEFT(value, 200) as value FROM pp_shop_apilo_settings ORDER BY name");
foreach ($stmt as $row) {
echo sprintf(" %-35s = %s\n", $row['name'], $row['value']);
}
echo "\n=== 2. PENDING ORDERS (apilo_order_id IS NULL) ===\n";
$stmt = $pdo->query("SELECT COUNT(*) as cnt FROM pp_shop_orders WHERE apilo_order_id IS NULL");
$row = $stmt->fetch();
echo " Total with NULL: " . $row['cnt'] . "\n";
$stmt = $pdo->query("SELECT id, date_order, status, apilo_order_id FROM pp_shop_orders WHERE apilo_order_id IS NULL ORDER BY date_order DESC LIMIT 10");
echo " Last 10 NULL orders:\n";
foreach ($stmt as $row) {
echo sprintf(" #%s date=%s status=%s\n", $row['id'], $row['date_order'], $row['status']);
}
echo "\n=== 3. FAILED ORDERS (apilo_order_id = -1) ===\n";
$stmt = $pdo->query("SELECT COUNT(*) as cnt FROM pp_shop_orders WHERE apilo_order_id = -1");
$row = $stmt->fetch();
echo " Total with -1: " . $row['cnt'] . "\n";
$stmt = $pdo->query("SELECT id, date_order, status FROM pp_shop_orders WHERE apilo_order_id = -1 ORDER BY date_order DESC LIMIT 10");
echo " Last 10 failed orders:\n";
foreach ($stmt as $row) {
echo sprintf(" #%s date=%s status=%s\n", $row['id'], $row['date_order'], $row['status']);
}
echo "\n=== 4. SKIPPED ORDERS (apilo_order_id = -2) ===\n";
$stmt = $pdo->query("SELECT COUNT(*) as cnt FROM pp_shop_orders WHERE apilo_order_id = -2");
$row = $stmt->fetch();
echo " Total with -2: " . $row['cnt'] . "\n";
echo "\n=== 5. RECENT APILO LOGS (pp_log) ===\n";
$stmt = $pdo->query("SELECT id, action, order_id, message, date FROM pp_log WHERE action LIKE '%apilo%' OR action LIKE '%send_order%' OR action LIKE '%token%' OR action LIKE '%keepalive%' ORDER BY id DESC LIMIT 20");
$rows = $stmt->fetchAll();
if (empty($rows)) {
echo " No Apilo logs found in pp_log\n";
// Try broader search
$stmt = $pdo->query("SELECT id, action, order_id, message, date FROM pp_log ORDER BY id DESC LIMIT 10");
$rows = $stmt->fetchAll();
echo " Last 10 logs (any type):\n";
}
foreach ($rows as $row) {
echo sprintf(" [%s] #%s action=%s order=%s msg=%s\n", $row['date'], $row['id'], $row['action'], $row['order_id'], substr($row['message'], 0, 120));
}
echo "\n=== 6. CRON JOBS STATUS ===\n";
$stmt = $pdo->query("SELECT job_type, status, COUNT(*) as cnt FROM pp_cron_jobs GROUP BY job_type, status ORDER BY job_type, status");
echo " Job type / status / count:\n";
foreach ($stmt as $row) {
echo sprintf(" %-30s %-12s %s\n", $row['job_type'], $row['status'], $row['cnt']);
}
echo "\n=== 7. RECENT CRON EXECUTIONS ===\n";
$stmt = $pdo->query("SELECT id, job_type, status, created_at, processed_at FROM pp_cron_jobs ORDER BY id DESC LIMIT 15");
foreach ($stmt as $row) {
echo sprintf(" #%s type=%s status=%s created=%s processed=%s\n", $row['id'], $row['job_type'], $row['status'], $row['created_at'], $row['processed_at']);
}
echo "\n=== 8. SUCCESSFULLY SENT ORDERS (last 5) ===\n";
$stmt = $pdo->query("SELECT id, date_order, apilo_order_id, status FROM pp_shop_orders WHERE apilo_order_id > 0 ORDER BY date_order DESC LIMIT 5");
foreach ($stmt as $row) {
echo sprintf(" #%s date=%s apilo_id=%s status=%s\n", $row['id'], $row['date_order'], $row['apilo_order_id'], $row['status']);
}
echo "\nDone.\n";

49
temp/diagnose_apilo2.php Normal file
View File

@@ -0,0 +1,49 @@
<?php
$pdo = new PDO("mysql:host=host700513.hostido.net.pl;dbname=host700513_pomysloweprezenty-pl;charset=utf8", 'host700513_pomysloweprezenty-pl', 'QBVbveHAzR78UN8pc7Um');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "=== CRON JOBS TABLE COLUMNS ===\n";
$stmt = $pdo->query("DESCRIBE pp_cron_jobs");
foreach ($stmt as $row) echo " " . $row['Field'] . " (" . $row['Type'] . ")\n";
echo "\n=== RECENT CRON JOBS (last 20) ===\n";
$stmt = $pdo->query("SELECT * FROM pp_cron_jobs ORDER BY id DESC LIMIT 20");
foreach ($stmt as $row) {
echo " #" . $row['id'] . " type=" . $row['job_type'] . " status=" . $row['status'] . " created=" . ($row['created_at'] ?? $row['date'] ?? 'n/a') . "\n";
}
echo "\n=== PENDING CRON JOBS ===\n";
$stmt = $pdo->query("SELECT * FROM pp_cron_jobs WHERE status = 'pending' ORDER BY id");
foreach ($stmt as $row) {
echo " #" . $row['id'] . " type=" . $row['job_type'];
foreach ($row as $k => $v) if ($v !== null && $k !== 'id' && $k !== 'job_type') echo " $k=$v";
echo "\n";
}
echo "\n=== NULL ORDERS SINCE 2026-03-15 (after last successful sync) ===\n";
$stmt = $pdo->query("SELECT id, date_order, status FROM pp_shop_orders WHERE apilo_order_id IS NULL AND date_order >= '2026-03-15 14:00:00' ORDER BY date_order ASC");
$rows = $stmt->fetchAll();
echo " Count: " . count($rows) . "\n";
foreach ($rows as $row) {
echo sprintf(" #%s date=%s status=%s\n", $row['id'], $row['date_order'], $row['status']);
}
echo "\n=== NULL ORDERS COUNT BY DATE RANGE ===\n";
$stmt = $pdo->query("SELECT
SUM(CASE WHEN date_order >= '2026-03-15 14:00:00' THEN 1 ELSE 0 END) as since_break,
SUM(CASE WHEN date_order >= '2024-08-20' AND date_order < '2026-03-15 14:00:00' THEN 1 ELSE 0 END) as before_break_after_sync_start,
SUM(CASE WHEN date_order < '2024-08-20' THEN 1 ELSE 0 END) as before_sync_start
FROM pp_shop_orders WHERE apilo_order_id IS NULL");
$row = $stmt->fetch();
echo " Since break (2026-03-15 14:00+): " . $row['since_break'] . "\n";
echo " Before break, after sync_start: " . $row['before_break_after_sync_start'] . "\n";
echo " Before sync_start (2024-08-20): " . $row['before_sync_start'] . "\n";
echo "\n=== LAST SUCCESSFUL SEND ORDER JOB ===\n";
$stmt = $pdo->query("SELECT * FROM pp_cron_jobs WHERE job_type = 'apilo_send_order' AND status = 'completed' ORDER BY id DESC LIMIT 3");
foreach ($stmt as $row) {
foreach ($row as $k => $v) if ($v !== null) echo " $k=$v";
echo "\n";
}
echo "\nDone.\n";

77
temp/fix_apilo_queue.php Normal file
View File

@@ -0,0 +1,77 @@
<?php
/**
* Fix stuck Apilo cron jobs on the instance after deploying the closure fix.
* Run once after uploading the fixed cron.php.
*
* Temporary — delete after use.
*/
$pdo = new PDO("mysql:host=host700513.hostido.net.pl;dbname=host700513_pomysloweprezenty-pl;charset=utf8", 'host700513_pomysloweprezenty-pl', 'QBVbveHAzR78UN8pc7Um');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "=== BEFORE FIX ===\n";
$stmt = $pdo->query("SELECT job_type, status, COUNT(*) as cnt FROM pp_cron_jobs WHERE status IN ('pending','processing','failed') GROUP BY job_type, status ORDER BY job_type");
foreach ($stmt as $row) {
echo " {$row['job_type']} {$row['status']} {$row['cnt']}\n";
}
// 1. Reset the stuck apilo_send_order job (attempts back to 0, clear error)
echo "\n=== Resetting apilo_send_order pending jobs ===\n";
$stmt = $pdo->prepare("UPDATE pp_cron_jobs SET attempts = 0, last_error = NULL, scheduled_at = NOW() WHERE job_type = 'apilo_send_order' AND status = 'pending'");
$stmt->execute();
echo " Reset {$stmt->rowCount()} apilo_send_order jobs\n";
// 2. Reset token keepalive failed jobs
echo "\n=== Resetting apilo_token_keepalive failed jobs ===\n";
$stmt = $pdo->prepare("UPDATE pp_cron_jobs SET attempts = 0, last_error = NULL, status = 'pending', scheduled_at = NOW() WHERE job_type = 'apilo_token_keepalive' AND status = 'failed'");
$stmt->execute();
echo " Reset {$stmt->rowCount()} token keepalive jobs\n";
// 3. Cancel sync_payment and sync_status jobs for orders that haven't been sent yet
// (these orders have apilo_order_id = NULL so sync is pointless — they'll be re-queued after order is sent)
echo "\n=== Cancelling premature sync jobs for unsent orders ===\n";
$stmt = $pdo->query("
SELECT cj.id, cj.job_type, cj.payload
FROM pp_cron_jobs cj
WHERE cj.status = 'pending'
AND cj.job_type IN ('apilo_sync_payment', 'apilo_sync_status')
");
$to_cancel = [];
foreach ($stmt as $row) {
$payload = json_decode($row['payload'], true);
$order_id = $payload['order_id'] ?? 0;
// Check if this order has been sent to Apilo
$check = $pdo->prepare("SELECT apilo_order_id FROM pp_shop_orders WHERE id = ?");
$check->execute([$order_id]);
$order = $check->fetch();
if ($order && ($order['apilo_order_id'] === null || (int)$order['apilo_order_id'] <= 0)) {
$to_cancel[] = $row['id'];
}
}
if ($to_cancel) {
$ids = implode(',', $to_cancel);
$pdo->exec("UPDATE pp_cron_jobs SET status = 'cancelled' WHERE id IN ($ids)");
echo " Cancelled " . count($to_cancel) . " premature sync jobs\n";
} else {
echo " No premature sync jobs to cancel\n";
}
// 4. Reset failed product_sync, pricelist_sync, status_poll jobs
echo "\n=== Resetting other failed Apilo jobs ===\n";
$stmt = $pdo->prepare("UPDATE pp_cron_jobs SET attempts = 0, last_error = NULL, scheduled_at = NOW() WHERE job_type IN ('apilo_product_sync', 'apilo_pricelist_sync', 'apilo_status_poll') AND status = 'pending'");
$stmt->execute();
echo " Reset {$stmt->rowCount()} other Apilo pending jobs\n";
echo "\n=== AFTER FIX ===\n";
$stmt = $pdo->query("SELECT job_type, status, COUNT(*) as cnt FROM pp_cron_jobs WHERE status IN ('pending','processing','failed') GROUP BY job_type, status ORDER BY job_type");
foreach ($stmt as $row) {
echo " {$row['job_type']} {$row['status']} {$row['cnt']}\n";
}
echo "\n=== ORDERS NEEDING SEND (apilo_order_id IS NULL, after sync_start) ===\n";
$stmt = $pdo->query("SELECT COUNT(*) as cnt FROM pp_shop_orders WHERE apilo_order_id IS NULL AND date_order >= '2024-08-20'");
$row = $stmt->fetch();
echo " " . $row['cnt'] . " orders to send\n";
echo " (cron processes 1 per run, should catch up within " . $row['cnt'] . " cron cycles)\n";
echo "\nDone. Deploy fixed cron.php to instance, then run this script.\n";

View File

@@ -0,0 +1,70 @@
<?php
/**
* Rebuild changelog-data.html from docs/CHANGELOG.md
* One-time script — delete after use.
*/
$md = file_get_contents(__DIR__ . '/../docs/CHANGELOG.md');
$lines = explode("\n", $md);
$entries = [];
$currentVersion = null;
$currentDate = null;
$currentLines = [];
foreach ($lines as $line) {
// Match: ## ver. 0.341 (2026-03-16) - Description here
if (preg_match('/^## ver\. ([\d.]+) \((\d{4}-\d{2}-\d{2})\) - (.+)$/', $line, $m)) {
// Save previous entry
if ($currentVersion !== null) {
$entries[] = [
'version' => $currentVersion,
'date' => $currentDate,
'lines' => $currentLines,
];
}
$currentVersion = $m[1];
$currentDate = $m[2];
$currentLines = [];
} elseif ($currentVersion !== null && trim($line) !== '' && trim($line) !== '---') {
// Clean markdown formatting for HTML
$clean = $line;
$clean = preg_replace('/^\- \*\*([A-Z]+)\*\*: /', '$1 - ', $clean);
$clean = preg_replace('/`([^`]+)`/', '$1', $clean);
$clean = str_replace(['**', '__'], '', $clean);
$clean = trim($clean);
if ($clean) {
$currentLines[] = $clean;
}
}
}
// Last entry
if ($currentVersion !== null) {
$entries[] = [
'version' => $currentVersion,
'date' => $currentDate,
'lines' => $currentLines,
];
}
// Build HTML
$html = '';
foreach ($entries as $entry) {
$dateParts = explode('-', $entry['date']);
$dateFormatted = $dateParts[2] . '.' . $dateParts[1] . '.' . $dateParts[0];
$desc = implode("\n", $entry['lines']);
$desc = htmlspecialchars($desc, ENT_QUOTES, 'UTF-8');
$html .= '<b>ver. ' . $entry['version'] . ' - ' . $dateFormatted . '</b><br />' . "\n";
$html .= $desc . "\n";
$html .= '<hr>' . "\n";
}
$outPath = __DIR__ . '/../updates/changelog-data.html';
file_put_contents($outPath, $html);
$size = filesize($outPath);
echo "Generated " . count($entries) . " entries\n";
echo "File size: " . number_format($size) . " bytes\n";
echo "Output: $outPath\n";

View File

@@ -1,20 +1,52 @@
<? if ( $this -> custom_fields ) : ?>
<? foreach ( $this -> custom_fields as $key => $val ) : ?>
<? $custom_field = ( new \Domain\Product\ProductRepository( $GLOBALS['mdb'] ) )->findCustomFieldCached( $key ); ?>
<? if ( $custom_field['type'] == 'text' ) : ?>
<div class="custom-field">
<div class="_name">
<?
echo $custom_field['name'] . ':';
?>
</div>
<div class="_text">
<?= $val;?>
</div>
</div>
<? elseif ( $custom_field['type'] == 'image' ) : ?>
<div class="custom-fields-display" data-product-code="<?= htmlspecialchars( $this->product_code ); ?>">
<? foreach ( $this -> custom_fields as $key => $val ) : ?>
<? $custom_field = ( new \Domain\Product\ProductRepository( $GLOBALS['mdb'] ) )->findCustomFieldCached( $key ); ?>
<? $field_type = !empty( $custom_field['type'] ) ? $custom_field['type'] : 'text'; ?>
<? endif; ?>
<? endforeach; ?>
<? endif;?>
<? if ( $field_type == 'text' ) : ?>
<div class="custom-field">
<div class="_name">
<?= htmlspecialchars( $custom_field['name'] ) . ':'; ?>
</div>
<div class="_text">
<?= nl2br( htmlspecialchars( $val ) );?>
</div>
</div>
<? elseif ( $field_type == 'image' && !empty( $val ) ) : ?>
<div class="custom-field">
<div class="_name">
<?= htmlspecialchars( $custom_field['name'] ) . ':'; ?>
</div>
<div class="_image">
<img src="<?= htmlspecialchars( $val );?>" alt="<?= htmlspecialchars( $custom_field['name'] );?>">
</div>
</div>
<? endif; ?>
<? endforeach; ?>
<a href="#" class="btn btn-sm btn-default btn-edit-custom-fields">Edytuj personalizację</a>
</div>
<div class="custom-fields-edit" data-product-code="<?= htmlspecialchars( $this->product_code ); ?>" style="display: none;">
<? foreach ( $this -> custom_fields as $key => $val ) : ?>
<? $custom_field = ( new \Domain\Product\ProductRepository( $GLOBALS['mdb'] ) )->findCustomFieldCached( $key ); ?>
<? $field_type = !empty( $custom_field['type'] ) ? $custom_field['type'] : 'text'; ?>
<? $is_required = !empty( $custom_field['is_required'] ) ? (int)$custom_field['is_required'] : 0; ?>
<div class="custom-field-edit-row" style="margin-bottom: 5px;">
<label>
<?= htmlspecialchars( $custom_field['name'] ); ?><?= $is_required ? ' <span style="color:red;">*</span>' : ''; ?>
</label>
<? if ( $field_type == 'text' ) : ?>
<input type="text" class="form-control form-control-sm" name="custom_field[<?= (int)$key; ?>]" value="<?= htmlspecialchars( $val ); ?>" <?= $is_required ? 'required' : ''; ?>>
<? elseif ( $field_type == 'image' ) : ?>
<input type="text" class="form-control form-control-sm" name="custom_field[<?= (int)$key; ?>]" value="<?= htmlspecialchars( $val ); ?>" placeholder="URL obrazka" <?= $is_required ? 'required' : ''; ?>>
<? endif; ?>
</div>
<? endforeach; ?>
<div style="margin-top: 5px;">
<a href="#" class="btn btn-sm btn-primary btn-save-custom-fields">Zapisz</a>
<a href="#" class="btn btn-sm btn-default btn-cancel-custom-fields">Anuluj</a>
</div>
</div>
<? endif; ?>

View File

@@ -61,7 +61,8 @@
<hr>
<? endif; ?>
<?= \Shared\Tpl\Tpl::view( 'shop-basket/_partials/product-custom-fields', [
'custom_fields' => $position['custom_fields']
'custom_fields' => $position['custom_fields'],
'product_code' => $position_hash
] ); ?>
<? if ( $product['additional_message'] ):?>
<div class="basket-product-message">

View File

@@ -1,4 +1,46 @@
<? global $settings; ?>
<?
if ( $settings['google_tag_manager_id'] && is_array( $this -> basket ) && count( $this -> basket ) ):
$view_cart_items = '';
$view_cart_value = 0;
foreach ( $this -> basket as $position ):
$vc_product = (new \Domain\Product\ProductRepository($GLOBALS['mdb']))->findCached( (int)$position['product-id'], (new \Domain\Languages\LanguagesRepository($GLOBALS['mdb']))->defaultLanguage() );
if ( !$vc_product )
continue;
$vc_price = (float)$vc_product['price_brutto_promo'] > 0 && (float)$vc_product['price_brutto_promo'] < (float)$vc_product['price_brutto']
? (float)$vc_product['price_brutto_promo']
: (float)$vc_product['price_brutto'];
$vc_qty = (int)$position['quantity'];
$view_cart_value += $vc_price * $vc_qty;
if ( $view_cart_items )
$view_cart_items .= ',';
$view_cart_items .= '{';
$view_cart_items .= 'item_id: "' . $vc_product['id'] . '",';
$view_cart_items .= 'item_name: "' . str_replace( '"', '', $vc_product['language']['name'] ) . '",';
$view_cart_items .= 'price: ' . \Shared\Helpers\Helpers::normalize_decimal( $vc_price ) . ',';
$view_cart_items .= 'quantity: ' . $vc_qty . ',';
$view_cart_items .= 'google_business_vertical: "retail"';
$view_cart_items .= '}';
endforeach;
?>
<script type="text/javascript">
dataLayer.push({ ecommerce: null });
dataLayer.push({
event: "view_cart",
ecommerce: {
currency: "PLN",
value: <?= \Shared\Helpers\Helpers::normalize_decimal( $view_cart_value );?>,
items: [<?= $view_cart_items;?>]
}
});
</script>
<? endif; ?>
<div id="basket-container">
<div id="content">
<?= $this->basket_details; ?>
@@ -508,4 +550,62 @@
console.warn('#orlen_point_id nie został znaleziony.');
}
});
// edycja personalizacji produktu w koszyku
$(document).on('click', '.btn-edit-custom-fields', function(e) {
e.preventDefault();
var $display = $(this).closest('.custom-fields-display');
var productCode = $display.data('product-code');
$display.hide();
$display.siblings('.custom-fields-edit[data-product-code="' + productCode + '"]').show();
});
$(document).on('click', '.btn-cancel-custom-fields', function(e) {
e.preventDefault();
var $edit = $(this).closest('.custom-fields-edit');
var productCode = $edit.data('product-code');
$edit.hide();
$edit.siblings('.custom-fields-display[data-product-code="' + productCode + '"]').show();
});
$(document).on('click', '.btn-save-custom-fields', function(e) {
e.preventDefault();
var $edit = $(this).closest('.custom-fields-edit');
var productCode = $edit.data('product-code');
var valid = true;
$edit.find('input[required]').each(function() {
if ($.trim($(this).val()) === '') {
$(this).css('border-color', 'red');
valid = false;
} else {
$(this).css('border-color', '');
}
});
if (!valid) {
alert('Wypełnij wszystkie wymagane pola');
return;
}
var formData = { product_code: productCode };
$edit.find('input[name^="custom_field"]').each(function() {
formData[$(this).attr('name')] = $(this).val();
});
$.ajax({
type: 'POST',
cache: false,
url: '/shopBasket/basket_update_custom_fields',
data: formData,
success: function(response) {
var data = jQuery.parseJSON(response);
if (data.result === 'ok') {
location.reload();
} else {
alert(data.message || 'Wystąpił błąd');
}
}
});
});
</script>

View File

@@ -73,10 +73,11 @@
$begin_checkout_items .= ',';
$begin_checkout_items .= '{';
$begin_checkout_items .= '"id": "' . $product['id'] . '",';
$begin_checkout_items .= '"name": "' . $product['language']['name'] . '",';
$begin_checkout_items .= '"item_id": "' . $product['id'] . '",';
$begin_checkout_items .= '"item_name": "' . str_replace( '"', '', $product['language']['name'] ) . '",';
$begin_checkout_items .= '"price": ' . \Shared\Helpers\Helpers::normalize_decimal( $price_product['price_new'] ) . ',';
$begin_checkout_items .= '"quantity": ' . $position['quantity'];
$begin_checkout_items .= '"quantity": ' . (int)$position['quantity'] . ',';
$begin_checkout_items .= '"google_business_vertical": "retail"';
$begin_checkout_items .= '}';
?>
<? endforeach;?>
@@ -96,11 +97,11 @@
<div class="basket-summary">
<?= $this -> transport[ 'name_visible' ];?>:
<? if ( $this -> transport[ 'delivery_free' ] == 1 ):?>
<? if ( $this -> free_delivery_applies ):?>
<span>0,00 zł</span>
<? else:?>
<span>
<?= \Shared\Helpers\Helpers::decimal( $this -> transport[ 'cost' ] );?> zł
<?= \Shared\Helpers\Helpers::decimal( $this -> transport_cost_effective );?> zł
</span>
<? endif;?>
</div>
@@ -110,7 +111,7 @@
$summary -= $discount;
?>
<span id="order-summary">
<?= $this -> transport[ 'delivery_free' ] == 1 ? \Shared\Helpers\Helpers::decimal( $summary ) : \Shared\Helpers\Helpers::decimal( $summary + $this -> transport[ 'cost' ] );?> zł
<?= $this -> free_delivery_applies ? \Shared\Helpers\Helpers::decimal( $summary ) : \Shared\Helpers\Helpers::decimal( $summary + $this -> transport_cost_effective );?> zł
</span>
</div>
<div class="basket-summary">

View File

@@ -169,17 +169,17 @@
event: "purchase",
ecommerce: {
transaction_id: "<?= $this -> order['id'];?>",
value: 25.42,
currency: "PLN",
value: <?= \Shared\Helpers\Helpers::normalize_decimal( round( $this -> order['summary'], 2 ) ) - str_replace( ',', '.', round( $this -> order['transport_cost'], 2 ) );?>,
shipping: <?= \Shared\Helpers\Helpers::normalize_decimal( $this -> order['transport_cost'] );?>,
items: [
<? foreach ( $this -> order['products'] as $product ):?>
{
'id': <?= (int)$product['product_id'];?>,
'name': '<?= $product['name'];?>',
'quantity': <?= $product['quantity'];?>,
'price': <?= ((float)$product['price_brutto_promo'] > 0 && (float)$product['price_brutto_promo'] < (float)$product['price_brutto']) ? (float)$product['price_brutto_promo'] : (float)$product['price_brutto'];?>
item_id: "<?= $product['product_id'];?>",
item_name: "<?= str_replace( '"', '', $product['name'] );?>",
quantity: <?= (int)$product['quantity'];?>,
price: <?= ((float)$product['price_brutto_promo'] > 0 && (float)$product['price_brutto_promo'] < (float)$product['price_brutto']) ? \Shared\Helpers\Helpers::normalize_decimal( $product['price_brutto_promo'] ) : \Shared\Helpers\Helpers::normalize_decimal( $product['price_brutto'] );?>,
google_business_vertical: "retail"
}<? if ( $product != end( $this -> order['products'] ) ) echo ',';?>
<? endforeach;?>
]

View File

@@ -2,17 +2,37 @@
global $lang_id;
$attributeRepo = new \Domain\Attribute\AttributeRepository( $GLOBALS['mdb'] );
$attribute_details = $attributeRepo->frontAttributeDetails( (int)$this -> attribute['id'], $lang_id );
$forced_value_id = null;
if ( isset( $_GET['permutation_hash'] ) && $_GET['permutation_hash'] !== '' )
{
$pairs = explode( '|', str_replace( '_', '|', $_GET['permutation_hash'] ) );
foreach ( $pairs as $pair )
{
$parts = explode( '-', $pair );
if ( count( $parts ) == 2 && (int)$parts[0] === (int)$this -> attribute['id'] )
{
$forced_value_id = (int)$parts[1];
break;
}
}
}
if ( $attribute_details['type'] == 0 )
{
?>
<div class="attribute-container fradio-group attribute-<?= \Shared\Helpers\Helpers::seo( $attribute_details['language']['name'] );?>" level="<?= $this -> level;?>" order="<?= $this -> order;?>" attribute="<?= \Shared\Helpers\Helpers::seo( $attribute_details['language']['name'] );?>" attribute-name="<?= $attribute_details['language']['name'];?>">
<p class="attribute-label"><?= $attribute_details['language']['name'];?>:</p>
<? foreach ( $this -> attribute['values'] as $value ):?>
<? foreach ( $this -> attribute['values'] as $value ):
$is_active = $forced_value_id !== null
? ( (int)$value['id'] === $forced_value_id )
: (bool)$value['is_default'];
?>
<div class="fradio">
<input type="radio" id="<?= $this -> attribute['id'];?>-<?= $value['id'];?>" <? if ( $value['is_default'] ):?>checked="checked"<? endif;?> require="true" value="<?= $this -> attribute['id'];?>-<?= $value['id'];?>" name="<?= \Shared\Helpers\Helpers::seo( $attribute_details['language']['name'] );?>">
<input type="radio" id="<?= $this -> attribute['id'];?>-<?= $value['id'];?>" <? if ( $is_active ):?>checked="checked"<? endif;?> require="true" value="<?= $this -> attribute['id'];?>-<?= $value['id'];?>" name="<?= \Shared\Helpers\Helpers::seo( $attribute_details['language']['name'] );?>">
<label for="<?= $this -> attribute['id'];?>-<?= $value['id'];?>" order="<?= $this -> order;?>"><?= ( new \Domain\Attribute\AttributeRepository( $GLOBALS['mdb'] ) )->getAttributeValueById( $value['id'], $lang_id );?></label>
</div>
<? if ( $value['is_default'] ):?>
<? if ( $is_active ):?>
<script class="footer" type="text/javascript">
$( function()
{

View File

@@ -275,12 +275,15 @@
dataLayer.push({
event: "view_item",
ecommerce: {
currency: "PLN",
value: <? if ( $this -> product['price_brutto_promo'] ): echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto_promo'] ); else: echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto'] ); endif;?>,
items: [
{
item_id: "<?= $this -> product['id'];?>",
item_name: "<?= str_replace( '"', '', $this -> product['language']['name'] );?>",
price: '<? if ( $this -> product['price_brutto_promo'] ): echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto_promo'] ); else: echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto'] ); endif;?>',
quantity: 1
price: <? if ( $this -> product['price_brutto_promo'] ): echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto_promo'] ); else: echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto'] ); endif;?>,
quantity: 1,
google_business_vertical: "retail"
}
]
}
@@ -617,7 +620,8 @@
item_id: "<?= $this -> product['id'];?>",
item_name: "<?= str_replace( '"', '', $this -> product['language']['name'] );?>",
price: <? if ( $this -> product['price_brutto_promo'] ): echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto_promo'] ); else: echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto'] ); endif;?>,
quantity: quantity
quantity: parseInt(quantity),
google_business_vertical: "retail"
}
]
}

View File

@@ -205,6 +205,7 @@ class CronJobRepositoryTest extends TestCase
{
// Job with attempts < max_attempts → reschedule with backoff
$this->mockDb->method('get')->willReturn([
'job_type' => 'price_history',
'max_attempts' => 10,
'attempts' => 2,
]);
@@ -228,6 +229,7 @@ class CronJobRepositoryTest extends TestCase
{
// Job with attempts >= max_attempts → permanent failure
$this->mockDb->method('get')->willReturn([
'job_type' => 'price_history',
'max_attempts' => 3,
'attempts' => 3,
]);
@@ -249,6 +251,7 @@ class CronJobRepositoryTest extends TestCase
public function testMarkFailedTruncatesErrorTo500Chars(): void
{
$this->mockDb->method('get')->willReturn([
'job_type' => 'price_history',
'max_attempts' => 10,
'attempts' => 1,
]);
@@ -268,6 +271,51 @@ class CronJobRepositoryTest extends TestCase
$this->repo->markFailed(1, $longError);
}
public function testMarkFailedApiloOrderJobNeverPermanentlyFails(): void
{
// apilo_send_order with max attempts reached → still pending (infinite retry)
$this->mockDb->method('get')->willReturn([
'job_type' => 'apilo_send_order',
'max_attempts' => 10,
'attempts' => 15,
]);
$this->mockDb->expects($this->once())
->method('update')
->with(
'pp_cron_jobs',
$this->callback(function ($data) {
return $data['status'] === 'pending'
&& isset($data['scheduled_at'])
&& isset($data['last_error']);
}),
['id' => 5]
);
$this->repo->markFailed(5, 'API timeout');
}
public function testMarkFailedApiloSyncPaymentInfiniteRetry(): void
{
$this->mockDb->method('get')->willReturn([
'job_type' => 'apilo_sync_payment',
'max_attempts' => 10,
'attempts' => 10,
]);
$this->mockDb->expects($this->once())
->method('update')
->with(
'pp_cron_jobs',
$this->callback(function ($data) {
return $data['status'] === 'pending';
}),
['id' => 6]
);
$this->repo->markFailed(6, 'Apilo unavailable');
}
// --- hasPendingJob ---
public function testHasPendingJobReturnsTrueWhenExists(): void

View File

@@ -0,0 +1,151 @@
<?php
namespace Tests\Unit\Domain\Product;
use PHPUnit\Framework\TestCase;
use Domain\Product\ProductRepository;
/**
* Phase 18 — testy generatora linku do feedu Google.
*
* ProductRepository::appendCombinationToXml buduje <link> dla pozycji
* feedu Google. permutation_hash w bazie ma format "attr-val|attr-val".
* W URL feedu separator między parami to "_" (nie "/"), żeby URL był
* jednym segmentem dopasowywalnym przez routing pp_routes.
*
* Test wywołuje prywatną metodę przez ReflectionMethod z minimalnymi
* danymi produktu i sprawdza zawartość wynikowego DOMDocument.
*/
class ProductFeedLinkTest extends TestCase
{
private function buildRepoWithMocks(): ProductRepository
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('select')->willReturn([]);
$mockDb->method('get')->willReturn(null);
$repo = new ProductRepository($mockDb);
// appendShippingToXml wywołuje $this->transportRepoForXml->lowestTransportPrice().
// Inicjalizacja w generateGoogleXmlFeed(); dla unit testu wstrzykujemy mock dynamicznie.
$transportMock = $this->getMockBuilder(\Domain\Transport\TransportRepository::class)
->disableOriginalConstructor()
->getMock();
$transportMock->method('lowestTransportPrice')->willReturn(0.0);
$repo->transportRepoForXml = $transportMock;
return $repo;
}
private function invokeAppendCombination(ProductRepository $repo, array $product, array $combination): string
{
$doc = new \DOMDocument('1.0', 'UTF-8');
$channelNode = $doc->appendChild($doc->createElement('channel'));
$method = new \ReflectionMethod(ProductRepository::class, 'appendCombinationToXml');
$method->setAccessible(true);
$method->invoke($repo, $doc, $channelNode, $product, $combination, 'https', 'shop.example.com');
return $doc->saveXML();
}
private function baseProduct(array $overrides = []): array
{
return array_merge([
'id' => 123,
'ean' => '5901234567890',
'language' => [
'name' => 'Produkt testowy',
'xml_name' => '',
'short_description' => 'Opis',
'meta_title' => '',
'seo_link' => 'sukienka-czerwona',
],
'price_brutto' => 100,
'price_brutto_promo' => 0,
'quantity' => 10,
'stock_0_buy' => 0,
'wp' => 1,
'images' => [],
], $overrides);
}
public function testCombinationLinkUsesUnderscoreInSeoLinkBranch()
{
$repo = $this->buildRepoWithMocks();
$product = $this->baseProduct();
$combination = [
'id' => 555,
'permutation_hash' => '20-170|21-175',
'price_brutto' => 120,
'price_brutto_promo' => 0,
'quantity' => 5,
'stock_0_buy' => 0,
];
$xml = $this->invokeAppendCombination($repo, $product, $combination);
$this->assertStringContainsString(
'<link>https://shop.example.com/sukienka-czerwona/20-170_21-175</link>',
$xml,
'Link feedu z seo_link musi używać "_" jako separatora par attr-val'
);
$this->assertStringNotContainsString(
'20-170/21-175',
$xml,
'Link feedu nie może zawierać starego separatora "/" między parami atrybutów'
);
}
public function testCombinationLinkUsesUnderscoreInFallbackBranch()
{
$repo = $this->buildRepoWithMocks();
$product = $this->baseProduct([
'language' => [
'name' => 'Sukienka czerwona',
'xml_name' => '',
'short_description' => 'Opis',
'meta_title' => '',
'seo_link' => '',
],
]);
$combination = [
'id' => 555,
'permutation_hash' => '20-170|21-175',
'price_brutto' => 120,
'price_brutto_promo' => 0,
'quantity' => 5,
'stock_0_buy' => 0,
];
$xml = $this->invokeAppendCombination($repo, $product, $combination);
// Fallback uses "p-{id}-{seo(name)}/...". Helpers::seo stub returns input unchanged.
$this->assertStringContainsString(
'<link>https://shop.example.com/p-123-Sukienka czerwona/20-170_21-175</link>',
$xml,
'Link fallback (bez seo_link) musi używać "_" jako separatora par attr-val'
);
}
public function testCombinationLinkWithSinglePair()
{
$repo = $this->buildRepoWithMocks();
$product = $this->baseProduct();
$combination = [
'id' => 555,
'permutation_hash' => '20-170',
'price_brutto' => 120,
'price_brutto_promo' => 0,
'quantity' => 5,
'stock_0_buy' => 0,
];
$xml = $this->invokeAppendCombination($repo, $product, $combination);
$this->assertStringContainsString(
'<link>https://shop.example.com/sukienka-czerwona/20-170</link>',
$xml,
'Pojedyncza para attr-val pozostaje bez zmian (str_replace nie ma co podmieniać)'
);
}
}

View File

@@ -1292,4 +1292,76 @@ class ProductRepositoryTest extends TestCase
$this->assertFalse($result);
}
public function testSaveCustomFieldsDeletesAllWhenEmpty(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->expects($this->once())
->method('delete')
->with(
$this->equalTo('pp_shop_products_custom_fields'),
$this->equalTo(['id_product' => 55])
);
$mockDb->expects($this->never())->method('insert');
$mockDb->expects($this->never())->method('update');
$repository = new ProductRepository($mockDb);
$method = new \ReflectionMethod(ProductRepository::class, 'saveCustomFields');
$method->setAccessible(true);
$method->invoke($repository, 55, [], [], []);
}
public function testCustomLabelNamesUsesDbSettingsWithFallback(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->expects($this->once())
->method('select')
->with(
'pp_settings',
['param', 'value'],
$this->callback(function ($where) {
return isset($where['param']) && is_array($where['param']) && count($where['param']) === 10;
})
)
->willReturn([
['param' => 'custom_label_0_name', 'value' => 'Sezon'],
['param' => 'google_custom_label_2_name', 'value' => 'Kampania'],
['param' => 'custom_label_4_name', 'value' => ''],
]);
$repository = new ProductRepository($mockDb);
$names = $repository->customLabelNames();
$this->assertSame('Sezon', $names['custom_label_0']);
$this->assertSame('Custom label 1', $names['custom_label_1']);
$this->assertSame('Kampania', $names['custom_label_2']);
$this->assertSame('Custom label 3', $names['custom_label_3']);
$this->assertSame('Custom label 4', $names['custom_label_4']);
}
public function testCustomLabelSuggestionsReturnsEmptyForInvalidLabelType(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->expects($this->never())->method('query');
$repository = new ProductRepository($mockDb);
$result = $repository->customLabelSuggestions('abc', 'custom_label_10');
$this->assertSame([], $result);
}
public function testSaveCustomLabelReturnsFalseForInvalidLabelType(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->expects($this->never())->method('update');
$repository = new ProductRepository($mockDb);
$result = $repository->saveCustomLabel(1, 'abc', 'custom_label_10');
$this->assertFalse($result);
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace Tests\Unit\Shared\Helpers;
use PHPUnit\Framework\TestCase;
/**
* Phase 18 — testy regex routingu pp_routes dla URL produktów z permutacją.
*
* Helpers::htacces() generuje pattern dla każdego produktu z permutacją.
* Pattern używa klasy znakowej [0-9_-]+, żeby dopasować segment "20-170_21-175"
* w jednym kawałku (separator pomiędzy parami atrybutów to "_", nie "/").
*
* Testy nie wywołują htacces() (zbyt duże zależności), tylko weryfikują:
* 1. Wzorzec literałem [0-9_-]+ występuje w generatorze pp_routes (file content)
* 2. Wzorzec przyjmuje URL z "_" i odrzuca wariant ze "/"
*/
class HelpersRoutingTest extends TestCase
{
private $helpersSource;
protected function setUp(): void
{
parent::setUp();
$this->helpersSource = file_get_contents(
__DIR__ . '/../../../../autoload/Shared/Helpers/Helpers.php'
);
}
public function testHelpersGeneratorUsesPermutationCharClassWithUnderscore()
{
// Liczba miejsc, gdzie pattern produktu z permutacją używa nowej klasy znaków.
$newPattern = substr_count($this->helpersSource, '/([0-9_-]+)$');
$this->assertGreaterThanOrEqual(
2,
$newPattern,
'Helpers.php musi zawierać dwa wystąpienia /([0-9_-]+)$ (gałąź seo_link i fallback p-id-name)'
);
// Stary wzorzec [0-9-]+ nie powinien już występować jako finalny segment URL.
$this->assertStringNotContainsString(
'/([0-9-]+)$',
$this->helpersSource,
'Stary wzorzec /([0-9-]+)$ został zastąpiony przez /([0-9_-]+)$ — nie powinno go już być w generatorze pp_routes'
);
}
public function testRegexMatchesUrlWithUnderscoreSeparator()
{
$pattern = '#^slug-produktu/([0-9_-]+)$#';
$matches = [];
$this->assertSame(
1,
preg_match($pattern, 'slug-produktu/20-170_21-175', $matches),
'Nowy wzorzec musi dopasować URL z "_" jako separatorem par atrybutów'
);
$this->assertSame('20-170_21-175', $matches[1]);
}
public function testRegexRejectsLegacyUrlWithSlashSeparator()
{
$pattern = '#^slug-produktu/([0-9_-]+)$#';
$this->assertSame(
0,
preg_match($pattern, 'slug-produktu/20-170/21-175'),
'Wzorzec NIE powinien dopasować starego URL ze "/" — taki URL ma trafiać do innego routingu lub 404'
);
}
public function testRegexMatchesSinglePairUrl()
{
$pattern = '#^slug-produktu/([0-9_-]+)$#';
$matches = [];
$this->assertSame(
1,
preg_match($pattern, 'slug-produktu/20-170', $matches),
'Wzorzec dopasowuje też URL z jedną parą attr-val'
);
$this->assertSame('20-170', $matches[1]);
}
}

View File

@@ -55,5 +55,38 @@ class ScontainersControllerTest extends TestCase
$this->assertEquals('Domain\Scontainers\ScontainersRepository', $params[0]->getType()->getName());
$this->assertEquals('Domain\Languages\LanguagesRepository', $params[1]->getType()->getName());
}
public function testBuildFormViewModelStoresIdInHiddenFieldsForEdit(): void
{
$reflection = new \ReflectionClass(ScontainersController::class);
$method = $reflection->getMethod('buildFormViewModel');
$method->setAccessible(true);
$container = [
'id' => 9,
'status' => 1,
'show_title' => 1,
'languages' => [],
];
$form = $method->invoke($this->controller, $container, [], null);
$this->assertArrayHasKey('id', $form->hiddenFields);
$this->assertSame(9, (int)$form->hiddenFields['id']);
$this->assertSame('/admin/scontainers/save/id=9', $form->action);
}
public function testBuildFormViewModelKeepsCreateFlowWithZeroId(): void
{
$reflection = new \ReflectionClass(ScontainersController::class);
$method = $reflection->getMethod('buildFormViewModel');
$method->setAccessible(true);
$form = $method->invoke($this->controller, [], [], null);
$this->assertArrayHasKey('id', $form->hiddenFields);
$this->assertSame(0, (int)$form->hiddenFields['id']);
$this->assertSame('/admin/scontainers/save/', $form->action);
}
}

View File

@@ -53,6 +53,7 @@ class ShopProductControllerTest extends TestCase
$this->assertTrue(method_exists($this->controller, 'product_unarchive'));
$this->assertTrue(method_exists($this->controller, 'product_delete'));
$this->assertTrue(method_exists($this->controller, 'change_product_status'));
$this->assertTrue(method_exists($this->controller, 'product_custom_labels_toggle'));
$this->assertTrue(method_exists($this->controller, 'product_change_price_brutto'));
$this->assertTrue(method_exists($this->controller, 'product_change_price_brutto_promo'));
$this->assertTrue(method_exists($this->controller, 'product_change_custom_label'));
@@ -128,6 +129,9 @@ class ShopProductControllerTest extends TestCase
'renderCustomFieldsBox',
'escapeHtml',
'resolveSavePayload',
'customLabelsEnabled',
'isAllowedCustomLabelType',
'renderCustomLabelsEditor',
];
foreach ($expectedPrivate as $method) {
@@ -147,4 +151,22 @@ class ShopProductControllerTest extends TestCase
$reflection = new \ReflectionClass($this->controller);
$this->assertEquals('void', (string)$reflection->getMethod('save')->getReturnType());
}
public function testToggleCustomLabelsMethodReturnsVoid(): void
{
$reflection = new \ReflectionClass($this->controller);
$this->assertEquals('void', (string)$reflection->getMethod('product_custom_labels_toggle')->getReturnType());
}
public function testAllowedCustomLabelTypeValidation(): void
{
$reflection = new \ReflectionClass($this->controller);
$method = $reflection->getMethod('isAllowedCustomLabelType');
$method->setAccessible(true);
$this->assertTrue($method->invoke($this->controller, 'custom_label_0'));
$this->assertTrue($method->invoke($this->controller, 'custom_label_4'));
$this->assertFalse($method->invoke($this->controller, 'custom_label_5'));
$this->assertFalse($method->invoke($this->controller, 'invalid'));
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace Tests\Unit\front\Controllers;
use PHPUnit\Framework\TestCase;
use front\Controllers\ShopBasketController;
use Domain\Order\OrderRepository;
use Domain\PaymentMethod\PaymentMethodRepository;
class ShopBasketControllerSummaryViewTest extends TestCase
{
private $controller;
protected function setUp(): void
{
$orderRepository = $this->createMock(OrderRepository::class);
$paymentMethodRepository = $this->createMock(PaymentMethodRepository::class);
$this->controller = new ShopBasketController($orderRepository, $paymentMethodRepository);
}
/**
* Wywoluje chroniona metode calculateTransportCostForSummary przez Reflection.
*
* @param array|null $transport
* @param float $productsSummary
* @param float $freeDeliveryThreshold
* @return array
*/
private function invokeCalc($transport, $productsSummary, $freeDeliveryThreshold): array
{
$reflection = new \ReflectionClass(ShopBasketController::class);
$method = $reflection->getMethod('calculateTransportCostForSummary');
$method->setAccessible(true);
return $method->invoke($this->controller, $transport, $productsSummary, $freeDeliveryThreshold);
}
public function testTransportWithDeliveryFreeBelowThresholdShowsRealCost(): void
{
// AC-1: delivery_free=1, basket 150, threshold 300 -> cost 15.00
$transport = [
'id' => 4,
'cost' => 15.00,
'delivery_free' => 1,
];
$result = $this->invokeCalc($transport, 150.00, 300.00);
$this->assertFalse($result['free_delivery_applies']);
$this->assertSame(15.00, $result['transport_cost_effective']);
}
public function testTransportWithDeliveryFreeAboveThresholdShowsZero(): void
{
// AC-2: delivery_free=1, basket 350, threshold 300 -> cost 0.0, applies true
$transport = [
'id' => 4,
'cost' => 15.00,
'delivery_free' => 1,
];
$result = $this->invokeCalc($transport, 350.00, 300.00);
$this->assertTrue($result['free_delivery_applies']);
$this->assertSame(0.0, $result['transport_cost_effective']);
}
public function testTransportWithDeliveryFreeAtExactThresholdShowsZero(): void
{
// Boundary: basket == threshold should trigger free delivery
$transport = [
'id' => 4,
'cost' => 20.00,
'delivery_free' => 1,
];
$result = $this->invokeCalc($transport, 300.00, 300.00);
$this->assertTrue($result['free_delivery_applies']);
$this->assertSame(0.0, $result['transport_cost_effective']);
}
public function testTransportWithoutDeliveryFreeAlwaysShowsCost(): void
{
// AC-3: delivery_free=0, basket 500, threshold 300 -> cost 25.00, applies false
$transport = [
'id' => 5,
'cost' => 25.00,
'delivery_free' => 0,
];
$result = $this->invokeCalc($transport, 500.00, 300.00);
$this->assertFalse($result['free_delivery_applies']);
$this->assertSame(25.00, $result['transport_cost_effective']);
}
public function testNullTransportReturnsZeroAndDoesNotApply(): void
{
// Scenario: no transport selected yet (findActiveByIdCached zwrocil null)
$result = $this->invokeCalc(null, 500.00, 300.00);
$this->assertFalse($result['free_delivery_applies']);
$this->assertSame(0.0, $result['transport_cost_effective']);
}
public function testZeroFreeDeliveryThresholdDisablesFreeDelivery(): void
{
// Ochrona: jesli settings.free_delivery = 0, darmowa dostawa nie dziala nigdy
$transport = [
'id' => 4,
'cost' => 15.00,
'delivery_free' => 1,
];
$result = $this->invokeCalc($transport, 9999.00, 0.00);
$this->assertFalse($result['free_delivery_applies']);
$this->assertSame(15.00, $result['transport_cost_effective']);
}
}

BIN
updates/0.30/ver_0.342.zip Normal file

Binary file not shown.

View File

@@ -0,0 +1,25 @@
{
"changelog": "Apilo: email z danymi zamówienia + infinite retry co 30 min dla order jobów",
"version": "0.342",
"files": {
"added": [
],
"deleted": [
],
"modified": [
"autoload/Domain/CronJob/CronJobRepository.php",
"autoload/Domain/CronJob/CronJobType.php",
"cron.php"
]
},
"checksum_zip": "sha256:1c1560ecdb4f83f62fa1c5b6c97f7c1a9640aa9b6c4927ad29acc96ff23d8ecd",
"sql": [
],
"date": "2026-03-19",
"directories_deleted": [
]
}

BIN
updates/0.30/ver_0.343.zip Normal file

Binary file not shown.

View File

@@ -0,0 +1,24 @@
{
"changelog": "Custom fields: type + is_required + obsługa obrazków w koszyku",
"version": "0.343",
"files": {
"added": [
],
"deleted": [
],
"modified": [
"autoload/Domain/Product/ProductRepository.php",
"templates/shop-basket/_partials/product-custom-fields.php"
]
},
"checksum_zip": "sha256:bd3b968a4b389c0c3fd00a511e5a3ef0405d4b60079d6b460335f93b8faa92d2",
"sql": [
],
"date": "2026-03-19",
"directories_deleted": [
]
}

BIN
updates/0.30/ver_0.344.zip Normal file

Binary file not shown.

View File

@@ -0,0 +1,26 @@
{
"changelog": "Edycja personalizacji produktu w koszyku",
"version": "0.344",
"files": {
"added": [
],
"deleted": [
],
"modified": [
"autoload/front/Controllers/ShopBasketController.php",
"templates/shop-basket/_partials/product-custom-fields.php",
"templates/shop-basket/basket-details.php",
"templates/shop-basket/basket.php"
]
},
"checksum_zip": "sha256:5d48acd5be4c2674cf8f0288e13b7dcad6ea9645aaa68c1d6af49e64b417d36f",
"sql": [
],
"date": "2026-03-19",
"directories_deleted": [
]
}

BIN
updates/0.30/ver_0.345.zip Normal file

Binary file not shown.

View File

@@ -0,0 +1,27 @@
{
"changelog": "Checkout flow fix - summaryView redirect, TTL token 30min, logowanie bledow zamowien",
"version": "0.345",
"files": {
"added": [
],
"deleted": [
],
"modified": [
"autoload/front/Controllers/ShopBasketController.php",
"templates/shop-basket/basket.php",
"templates/shop-basket/summary-view.php",
"templates/shop-order/order-details.php",
"templates/shop-product/product.php"
]
},
"checksum_zip": "sha256:805e4d80fe75679c937974059594984377a81cad84a1308d9deefb96e0b39319",
"sql": [
],
"date": "2026-03-25",
"directories_deleted": [
]
}

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