Compare commits

...

19 Commits

Author SHA1 Message Date
eb7badab65 docs: update CHANGELOG for v0.329 and v0.330
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 22:50:24 +01:00
d83d0ecdea feat: eliminate htaccess.conf, move all URL routes to pp_routes (v0.329-0.330)
- Add category_id, page_id, article_id, type columns to pp_routes (migration 0.329)
- Move routing block in index.php before checkUrlParams() with Redis cache
- Routes for categories, pages, articles now stored in pp_routes instead of .htaccess
- Delete category/page/article routes on entity delete in respective repositories
- Eliminate libraries/htaccess.conf: generate .htaccess content entirely from PHP
- Move 32 static system routes (koszyk, logowanie, newsletter, AJAX modules, etc.)
  plus dynamic language/producer routes to pp_routes with type='system'
- Invalidate pp_routes Redis cache on every htacces() regeneration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 22:06:33 +01:00
b8ed7a46d8 build: update package v0.328 — copy icon for order attribute values
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 20:51:20 +01:00
f9e5efbffb docs: update CHANGELOG for v0.328
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 20:50:51 +01:00
7fc8cff474 feat: copy icon for attribute values in order details
Each attribute in .atributes div gets a clipboard icon button.
Click copies the value, icon switches to checkmark for 1.5s.
Uses Clipboard API with textarea fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 20:49:01 +01:00
218a0e8956 build: update package v0.327 — bulk delete in product archive
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 20:40:22 +01:00
0a14c92109 feat: bulk delete in product archive (v0.327)
- Add bulk_delete_permanent() endpoint (POST ids[], returns JSON)
- Checkbox column + bulk action bar with count label
- Select-all in table header, confirmation dialog before delete
- 2 new tests for bulk_delete_permanent method signature

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 20:37:22 +01:00
c59501603d build: update package v0.326 — API categories/list endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 20:17:14 +01:00
6f94daeb76 docs: update API.md, CHANGELOG, PROJECT_STRUCTURE for categories/list endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 20:13:36 +01:00
72159062f5 fix: remove dead CategoryRepository param, fix N+1 queries in categories/list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 18:54:48 +01:00
2461087d9b feat: add categories/list API endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 18:51:26 +01:00
6434933dfb Add configuration for cron key and document code style conventions
- Added cron key to config.php for scheduled tasks.
- Created code_style_and_conventions.md to outline PHP version, file naming, DI pattern, controller wiring, Medoo ORM pitfalls, test conventions, caching, and database structure.
- Added project_overview.md detailing the purpose, tech stack, architecture, entry points, and key classes of the shopPRO project.
- Introduced suggested_commands.md for testing and system utilities commands.
- Added task_completion_checklist.md for a structured approach to completing tasks.
- Included .DS_Store files in autoload and templates directories for macOS compatibility.
2026-02-27 14:57:02 +01:00
28f53b7998 fix: broken SQL in update manifests — line-by-line instead of complete statements
build-update.ps1 was reading SQL migrations line-by-line, causing
multi-line CREATE TABLE/INSERT statements to be stored as fragments
in manifests. Fixed to strip comments, join lines, and split by
semicolons. Fixed ver_0.324_manifest.json with correct SQL statements.
Added try-catch in UpdateRepository to prevent fatal crashes on SQL errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:48:08 +01:00
98029b1720 build: update package v0.325
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 13:52:59 +01:00
a531fabeaf fix: changelog encoding (mojibake) + limit display to 5 versions back
Rebuilt changelog data from manifest JSON files to fix garbled Polish
characters. Converted changelog.php from static HTML to PHP script that
filters entries by instance version (?ver= parameter).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 13:46:09 +01:00
c53778ab36 build: update package v0.324
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 13:32:36 +01:00
52119a0724 feat: database-backed cron job queue replacing JSON file system
Replace file-based JSON cron queue with DB-backed job queue (pp_cron_jobs,
pp_cron_schedules). New Domain\CronJob module: CronJobType (constants),
CronJobRepository (CRUD, atomic fetch, retry/backoff), CronJobProcessor
(orchestration with handler registration). Priority ordering guarantees
apilo_send_order (40) runs before sync tasks (50). Includes cron.php auth
protection, race condition fix in fetchNext, API response validation,
and DI wiring across all entry points. 41 new tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 13:29:11 +01:00
97d7473753 feat: add language backend configuration and update documentation
- Added `language_backend` option to project.yml for specifying the language backend (LSP or JetBrains).
- Updated CLAUDE.md with a note on downloading log files from the FTP server.
- Removed unnecessary .DS_Store files from autoload and templates directories.
- Deleted outdated log files from the logs directory.
2026-02-24 21:14:14 +01:00
754f004096 build: update package v0.323
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:12:48 +01:00
71 changed files with 4638 additions and 804 deletions

View File

@@ -67,7 +67,10 @@
"Bash(rm -rf \"C:/visual studio code/projekty/shopPRO/temp/temp_314\" && cd \"C:/visual studio code/projekty/shopPRO\" && powershell -ExecutionPolicy Bypass -File build-update.ps1 -FromTag v0.313 -ToTag v0.314 -ChangelogEntry \"FIX - naprawa globalnej wyszukiwarki admin \\(Content-Type, Cache-Control, POST, try/catch\\), NEW - title strony z numerem zamówienia\" 2>&1)",
"mcp__serena__initial_instructions",
"mcp__serena__list_memories",
"mcp__serena__find_referencing_symbols"
"mcp__serena__find_referencing_symbols",
"Bash(cd C:\\\\visual studio code\\\\projekty\\\\shopPRO:*)",
"Bash(cd \"/c/visual studio code/projekty/shopPRO\" && rm -rf temp/temp_317 && powershell -ExecutionPolicy Bypass -File build-update.ps1 -FromTag v0.316 -ToTag v0.317 -ChangelogEntry \"FIX - klucz API: fix zapisu \\(brakowalo w whiteliście\\), przycisk Generuj losowy klucz, ulepszony routing API\" 2>&1)",
"Bash(./test.ps1)"
]
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,47 @@
# Code Style and Conventions
## PHP Version
PHP 7.4 — no PHP 8.0+ features allowed.
## File Naming
- New classes: `ClassName.php` (no prefix)
- Legacy classes: `class.ClassName.php` (leave until migrated)
## DI Pattern (all new code)
```php
class ExampleRepository {
private $db;
public function __construct($db) {
$this->db = $db;
}
public function find(int $id): ?array {
return $this->db->get('pp_table', '*', ['id' => $id]);
}
}
```
## Controller Wiring
- Admin: `admin\App::getControllerFactories()`
- Frontend: `front\App::getControllerFactories()`
- API: `api\ApiRouter::getControllerFactories()`
## Medoo ORM Pitfalls
- `$mdb->delete($table, $where)` takes 2 arguments, NOT 3
- `$mdb->get()` returns `null` when no record, NOT `false`
- After `$mdb->insert()`, check `$mdb->id()` to confirm success
## Test Conventions
- Extend `PHPUnit\Framework\TestCase`
- Mock Medoo: `$this->createMock(\medoo::class)`
- AAA pattern: Arrange, Act, Assert
- Mirror source structure: `tests/Unit/Domain/{Module}/{Class}Test.php`
## Caching
- Redis via `\Shared\Cache\CacheHandler`
- Key pattern: `shop\product:{id}:{lang}:{permutation_hash}`
- Default TTL: 86400 (24h)
- Data serialized — use `unserialize()` after `get()`
## Database
- Table prefix: `pp_`
- Key tables: `pp_shop_products`, `pp_shop_orders`, `pp_shop_categories`, `pp_shop_clients`

View File

@@ -0,0 +1,65 @@
# shopPRO — Project Overview
## Purpose
shopPRO is a PHP e-commerce platform with an admin panel, customer-facing storefront, and REST API.
## Tech Stack
- **Language**: PHP 7.4 (production runs PHP < 8.0 — do NOT use PHP 8.0+ syntax!)
- **ORM**: Medoo (`$mdb` global, injected via DI in new code)
- **Caching**: Redis via `\Shared\Cache\CacheHandler`
- **Testing**: PHPUnit 9.6 via `phpunit.phar`
- **Frontend**: Custom template engine (`\Shared\Tpl\Tpl`)
- **Database**: MySQL with `pp_` table prefix
- **Platform**: Windows (development), Linux (production)
## PHP 7.4 Constraint — CRITICAL
Do NOT use any PHP 8.0+ features:
- No `match` expressions (use ternary/if-else)
- No named arguments
- No union types (`int|string`)
- No `str_contains()`, `str_starts_with()`, `str_ends_with()`
## Architecture
Domain-Driven Design with Dependency Injection.
### Layers
1. **Domain** (`autoload/Domain/`) — Business logic repositories, 27 modules
2. **Admin** (`autoload/admin/`) — Admin panel controllers, support, validation, view models
3. **Frontend** (`autoload/front/`) — Customer-facing controllers and views
4. **API** (`autoload/api/`) — REST API controllers
5. **Shared** (`autoload/Shared/`) — Cache, Email, Helpers, Html, Image, Tpl
### Domain Modules
Article, Attribute, Banner, Basket, Cache, Category, Client, Coupon, Dashboard, Dictionaries, Integrations, Languages, Layouts, Newsletter, Order, Pages, PaymentMethod, Producer, Product, ProductSet, Promotion, Scontainers, Settings, ShopStatus, Transport, Update, User
### Entry Points
- `index.php` — Frontend
- `admin/index.php` — Admin panel
- `api.php` — REST API
- `ajax.php` — Frontend AJAX
- `admin/ajax.php` — Admin AJAX
- `cron.php` — CRON jobs
### Namespace Conventions (case-sensitive on Linux!)
- `\Domain\``autoload/Domain/` (uppercase D)
- `\admin\Controllers\``autoload/admin/Controllers/` (lowercase a)
- `\Shared\``autoload/Shared/`
- `\front\``autoload/front/`
- `\api\``autoload/api/`
### Autoloader
Custom autoloader (not Composer at runtime). Tries:
1. `autoload/{namespace}/class.{ClassName}.php` (legacy)
2. `autoload/{namespace}/{ClassName}.php` (PSR-4 style)
### Key Classes
- `\admin\App` — Admin router
- `\front\App` — Frontend router
- `\front\LayoutEngine` — Frontend layout engine
- `\Shared\Helpers\Helpers` — Utility methods
- `\Shared\Tpl\Tpl` — Template engine
- `\Shared\Cache\CacheHandler` — Redis cache
- `\api\ApiRouter` — REST API router
## Test Suite
765 tests, 2153 assertions. Tests mirror source structure in `tests/Unit/`.

View File

@@ -0,0 +1,41 @@
# Suggested Commands
## Testing
```bash
# Full test suite (recommended, PowerShell)
./test.ps1
# Specific test file
./test.ps1 tests/Unit/Domain/Product/ProductRepositoryTest.php
# Specific test method
./test.ps1 --filter testGetQuantityReturnsCorrectValue
# Via composer
composer test
```
## System Utilities (Windows with Git Bash)
```bash
# Use Unix-style commands (Git Bash shell)
ls # list directory
grep -r # search content (prefer Serena tools instead)
git status # git operations
git log --oneline -10
git diff
git add <file>
git commit -m "message"
git push
```
## Development
```bash
# No build step — PHP is interpreted
# No linting/formatting tool configured
# Entry points are served via web server (XAMPP)
```
## PHP binary
```
C:\xampp\php\php.exe
```

View File

@@ -0,0 +1,25 @@
# Task Completion Checklist
When user says "KONIEC PRACY", execute in order:
1. **Run tests**`./test.ps1`
2. **Update documentation if needed**:
- `docs/DATABASE_STRUCTURE.md`
- `docs/PROJECT_STRUCTURE.md`
- `docs/FORM_EDIT_SYSTEM.md`
- `docs/CHANGELOG.md`
- `docs/TESTING.md`
3. **SQL migrations** (if DB changes): place in `migrations/{version}.sql`
- NOT in `updates/` — build script reads from `migrations/` automatically
4. **Commit** changes
5. **Push** to remote
## Key Documentation Files
- `docs/MEMORY.md` — project memory, known issues
- `docs/PROJECT_STRUCTURE.md` — architecture
- `docs/DATABASE_STRUCTURE.md` — full DB schema
- `docs/TESTING.md` — test suite guide
- `docs/FORM_EDIT_SYSTEM.md` — form system
- `docs/CHANGELOG.md` — version history
- `docs/API.md` — REST API docs
- `docs/UPDATE_INSTRUCTIONS.md` — update packages

View File

@@ -116,3 +116,10 @@ initial_prompt: ""
# override of the corresponding setting in serena_config.yml, see the documentation there.
# If null or missing, the value from the global config is used.
symbol_info_budget:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:

View File

@@ -36,6 +36,9 @@ layout/style-scss/style.scss
layout/style-scss/_mixins.scss
layout/style-scss/_mixins.css
# macOS metadata
*.DS_Store
# Temp / cache / backups
temp/
backups/

View File

@@ -36,7 +36,7 @@ composer test
PHPUnit 9.6 via `phpunit.phar`. Bootstrap: `tests/bootstrap.php`. Config: `phpunit.xml`.
Current suite: **765 tests, 2153 assertions**.
Current suite: **805 tests, 2253 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.
@@ -116,7 +116,7 @@ All legacy directories (`admin/controls/`, `admin/factory/`, `admin/view/`, `fro
- Constructor DI with `$db` (Medoo instance)
- Methods serve both admin and frontend (shared Domain, no separate services)
**Domain Modules**: Article, Attribute, Banner, Basket, Cache, Category, Client, Coupon, Dashboard, Dictionaries, Integrations, Languages, Layouts, Newsletter, Order, Pages, PaymentMethod, Producer, Product, ProductSet, Promotion, Scontainers, Settings, ShopStatus, Transport, Update, User
**Domain Modules**: 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
**Admin Controllers** (`autoload/admin/Controllers/`):
- DI via constructor (repositories injected)
@@ -223,3 +223,5 @@ Before starting implementation, review current state of docs (see AGENTS.md for
- `docs/CHANGELOG.md` — version history
- `docs/API.md` — 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

View File

@@ -1,4 +1,26 @@
<style type="text/css">
.bulk-action-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
margin-bottom: 10px;
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
}
.bulk-action-bar__info {
font-weight: 600;
color: #856404;
}
.table-col-bulk-check {
width: 36px;
padding-left: 10px !important;
padding-right: 10px !important;
}
.product-archive-thumb-wrap {
display: inline-block;
}
@@ -96,5 +118,119 @@
$popup.removeClass('is-visible');
$popupImage.attr('src', '');
});
// --- Bulk select ---
var $table = $('.table-list-table');
var $bar = $('#js-bulk-action-bar');
var $label = $bar.find('.js-bulk-count-label');
// Inject select-all checkbox into _checkbox column header
$table.find('thead th.table-col-bulk-check').html(
'<input type="checkbox" id="js-bulk-select-all" title="Zaznacz wszystkie">'
);
function updateBar() {
var count = $table.find('.js-bulk-check:checked').length;
if (count > 0) {
$label.text('Zaznaczono: ' + count);
$bar.show();
} else {
$bar.hide();
}
}
$(document).on('change.bulkSelect', '#js-bulk-select-all', function() {
var checked = $(this).is(':checked');
$table.find('.js-bulk-check').prop('checked', checked);
updateBar();
});
$(document).on('change.bulkSelect', '.js-bulk-check', function() {
var total = $table.find('.js-bulk-check').length;
var checked = $table.find('.js-bulk-check:checked').length;
$('#js-bulk-select-all').prop('indeterminate', checked > 0 && checked < total);
$('#js-bulk-select-all').prop('checked', checked === total && total > 0);
updateBar();
});
$(document).on('click.bulkDelete', '.js-bulk-delete-btn', function() {
var ids = [];
$table.find('.js-bulk-check:checked').each(function() {
ids.push($(this).val());
});
if (ids.length === 0) {
return;
}
var confirmMsg = 'UWAGA! Operacja nieodwracalna!\n\n'
+ 'Wybrane produkty (' + ids.length + ' szt.) zostaną trwale usunięte razem ze wszystkimi zdjęciami i załącznikami z serwera.\n\n'
+ 'Czy na pewno chcesz usunąć zaznaczone produkty?';
var doDelete = function() {
var $btn = $('.js-bulk-delete-btn');
$btn.prop('disabled', true).text('Usuwanie…');
var formData = [];
for (var i = 0; i < ids.length; i++) {
formData.push('ids%5B%5D=' + encodeURIComponent(ids[i]));
}
$.ajax({
url: '/admin/product_archive/bulk_delete_permanent/',
type: 'POST',
data: formData.join('&'),
contentType: 'application/x-www-form-urlencoded',
dataType: 'json',
success: function(resp) {
if (resp && resp.deleted > 0) {
window.location.reload();
} else {
alert('Nie udało się usunąć produktów. Spróbuj ponownie.');
$btn.prop('disabled', false).html('<i class="fa fa-trash-o"></i> Usuń zaznaczone trwale');
}
},
error: function() {
alert('Błąd podczas usuwania produktów. Spróbuj ponownie.');
$btn.prop('disabled', false).html('<i class="fa fa-trash-o"></i> Usuń zaznaczone trwale');
}
});
};
if (typeof $.confirm === 'function') {
$.confirm({
title: 'Potwierdzenie',
content: confirmMsg,
type: 'red',
boxWidth: '560px',
useBootstrap: false,
animation: 'scale',
closeAnimation: 'scale',
backgroundDismissAnimation: 'shake',
container: 'body',
theme: 'modern',
columnClass: '',
typeAnimated: true,
lazyOpen: false,
draggable: false,
closeIcon: true,
containerFluid: true,
escapeKey: true,
backgroundDismiss: true,
buttons: {
cancel: {
text: 'Anuluj',
btnClass: 'btn-default'
},
confirm: {
text: 'Tak, usuń trwale',
btnClass: 'btn-danger',
action: doDelete
}
}
});
} else if (window.confirm(confirmMsg)) {
doDelete();
}
});
})(window.jQuery);
</script>

View File

@@ -1,3 +1,10 @@
<div id="js-bulk-action-bar" class="bulk-action-bar" style="display:none;">
<span class="bulk-action-bar__info js-bulk-count-label">Zaznaczono: 0</span>
<button type="button" class="btn btn-danger btn-sm js-bulk-delete-btn">
<i class="fa fa-trash-o"></i> Usuń zaznaczone trwale
</button>
</div>
<?= \Shared\Tpl\Tpl::view('components/table-list', ['list' => $this->viewModel]); ?>
<?php if (!empty($this->viewModel->customScriptView)): ?>

View File

@@ -1,3 +1,30 @@
<style type="text/css">
.attr-copy-btn {
display: inline-block;
padding: 1px 5px;
font-size: 11px;
line-height: 1.5;
background: transparent;
border: 1px solid #d0d0d0;
border-radius: 3px;
color: #999;
cursor: pointer;
vertical-align: middle;
margin-left: 4px;
transition: background .12s, color .12s, border-color .12s;
}
.attr-copy-btn:hover {
background: #f4f4f4;
border-color: #aaa;
color: #555;
}
.attr-copy-btn--copied {
background: #d4edda !important;
border-color: #28a745 !important;
color: #28a745 !important;
}
</style>
<script type="text/javascript">
(function() {
var orderId = <?= (int)($this->order_id ?? 0);?>;
@@ -378,6 +405,68 @@
});
}
$(function() {
function fallbackCopy(text) {
var $tmp = $('<textarea>').css({position: 'fixed', top: 0, left: 0, opacity: 0}).val(text);
$('body').append($tmp);
$tmp[0].select();
try { document.execCommand('copy'); } catch (e) {}
$tmp.remove();
}
$('.atributes').each(function() {
var $div = $(this);
var html = $.trim($div.html());
if (!html) { return; }
var parts = html.split(/<br\s*\/?>/i);
var newParts = [];
for (var i = 0; i < parts.length; i++) {
var part = $.trim(parts[i]);
if (!part) { continue; }
var match = part.match(/^(<b>[^<]*<\/b>\s*:\s*)(.+)$/);
if (match) {
var labelHtml = match[1];
var value = $.trim(match[2]);
var escapedValue = $('<div>').text(value).html();
part = labelHtml + escapedValue
+ ' <button type="button" class="js-attr-copy-btn attr-copy-btn" data-value="'
+ escapedValue + '" title="Kopiuj: ' + escapedValue + '">'
+ '<i class="fa fa-copy"></i></button>';
}
newParts.push(part);
}
$div.html(newParts.join('<br>'));
});
$(document).on('click', '.js-attr-copy-btn', function() {
var $btn = $(this);
var value = String($btn.data('value'));
function showCopied() {
$btn.addClass('attr-copy-btn--copied');
$btn.find('i').removeClass('fa-copy').addClass('fa-check');
setTimeout(function() {
$btn.removeClass('attr-copy-btn--copied');
$btn.find('i').removeClass('fa-check').addClass('fa-copy');
}, 1500);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(value).then(showCopied, function() {
fallbackCopy(value);
showCopied();
});
} else {
fallbackCopy(value);
showCopied();
}
});
});
$('body').on('click', '.btn-toggle-trustmate', function(e) {
e.preventDefault();

View File

@@ -57,7 +57,7 @@
<span class="panel-title">Changelog</span>
</div>
<div class="panel-body">
<?= @file_get_contents( 'https://shoppro.project-dc.pl/updates/changelog.php' ); ?>
<?= @file_get_contents( 'https://shoppro.project-dc.pl/updates/changelog.php?ver=' . $this->ver ); ?>
</div>
</div>

View File

@@ -360,6 +360,9 @@ class ArticleRepository
public function archive(int $articleId): bool
{
$result = $this->db->update('pp_articles', ['status' => -1], ['id' => $articleId]);
if ($result) {
$this->db->delete('pp_routes', ['article_id' => $articleId]);
}
return (bool)$result;
}
@@ -381,6 +384,7 @@ class ArticleRepository
$this->db->delete('pp_articles_langs', ['article_id' => $articleId]);
$this->db->delete('pp_articles_images', ['article_id' => $articleId]);
$this->db->delete('pp_articles_files', ['article_id' => $articleId]);
$this->db->delete('pp_routes', ['article_id' => $articleId]);
$this->db->delete('pp_articles', ['id' => $articleId]);
\Shared\Helpers\Helpers::delete_dir('../upload/article_images/article_' . $articleId . '/');

View File

@@ -174,6 +174,7 @@ class CategoryRepository
$deleted = (bool)$this->db->delete('pp_shop_categories', ['id' => $id]);
if ($deleted) {
$this->db->delete('pp_routes', ['category_id' => $id]);
$this->refreshCategoryArtifacts();
}

View File

@@ -0,0 +1,140 @@
<?php
namespace Domain\CronJob;
class CronJobProcessor
{
/** @var CronJobRepository */
private $cronRepo;
/** @var array<string, callable> */
private $handlers = [];
/**
* @param CronJobRepository $cronRepo
*/
public function __construct(CronJobRepository $cronRepo)
{
$this->cronRepo = $cronRepo;
}
/**
* Zarejestruj handler dla typu zadania
*
* @param string $jobType
* @param callable $handler fn($payload): bool|array — true/array = success, false/exception = fail
*/
public function registerHandler($jobType, callable $handler)
{
$this->handlers[$jobType] = $handler;
}
/**
* Utwórz zadania z harmonogramów, których next_run_at <= NOW
*
* @return int Liczba utworzonych zadań
*/
public function createScheduledJobs()
{
$schedules = $this->cronRepo->getDueSchedules();
$created = 0;
foreach ($schedules as $schedule) {
$jobType = $schedule['job_type'];
// Nie twórz duplikatów
if ($this->cronRepo->hasPendingJob($jobType)) {
// Mimo duplikatu, przesuń next_run_at żeby nie sprawdzać co sekundę
$this->cronRepo->touchSchedule($schedule['id'], (int) $schedule['interval_seconds']);
continue;
}
$payload = null;
if (!empty($schedule['payload'])) {
$payload = json_decode($schedule['payload'], true);
}
$this->cronRepo->enqueue(
$jobType,
$payload,
(int) $schedule['priority'],
(int) $schedule['max_attempts']
);
$this->cronRepo->touchSchedule($schedule['id'], (int) $schedule['interval_seconds']);
$created++;
}
return $created;
}
/**
* Przetwórz kolejkę zadań
*
* @param int $limit
* @return array Statystyki: ['processed' => int, 'succeeded' => int, 'failed' => int, 'skipped' => int]
*/
public function processQueue($limit = 10)
{
$stats = ['processed' => 0, 'succeeded' => 0, 'failed' => 0, 'skipped' => 0];
$jobs = $this->cronRepo->fetchNext($limit);
foreach ($jobs as $job) {
$jobType = $job['job_type'];
$jobId = (int) $job['id'];
$stats['processed']++;
if (!isset($this->handlers[$jobType])) {
$this->cronRepo->markFailed($jobId, 'No handler registered for job type: ' . $jobType, (int) $job['attempts']);
$stats['skipped']++;
continue;
}
try {
$result = call_user_func($this->handlers[$jobType], $job['payload']);
if ($result === false) {
$this->cronRepo->markFailed($jobId, 'Handler returned false', (int) $job['attempts']);
$stats['failed']++;
} else {
$resultData = is_array($result) ? $result : null;
$this->cronRepo->markCompleted($jobId, $resultData);
$stats['succeeded']++;
}
} catch (\Exception $e) {
$this->cronRepo->markFailed($jobId, $e->getMessage(), (int) $job['attempts']);
$stats['failed']++;
} catch (\Throwable $e) {
$this->cronRepo->markFailed($jobId, $e->getMessage(), (int) $job['attempts']);
$stats['failed']++;
}
}
return $stats;
}
/**
* Główna metoda: utwórz scheduled jobs + przetwórz kolejkę
*
* @param int $limit
* @return array ['scheduled' => int, 'processed' => int, 'succeeded' => int, 'failed' => int, 'skipped' => int]
*/
public function run($limit = 20)
{
// Odzyskaj stuck jobs
$this->cronRepo->recoverStuck(30);
// Utwórz zadania z harmonogramów
$scheduled = $this->createScheduledJobs();
// Przetwórz kolejkę
$stats = $this->processQueue($limit);
$stats['scheduled'] = $scheduled;
// Cleanup starych zadań (raz na uruchomienie)
$this->cronRepo->cleanup(30);
return $stats;
}
}

View File

@@ -0,0 +1,248 @@
<?php
namespace Domain\CronJob;
class CronJobRepository
{
/** @var \medoo */
private $db;
/**
* @param \medoo $db
*/
public function __construct($db)
{
$this->db = $db;
}
/**
* Dodaj zadanie do kolejki
*
* @param string $jobType
* @param array|null $payload
* @param int $priority
* @param int $maxAttempts
* @param string|null $scheduledAt
* @return int|null ID nowego zadania
*/
public function enqueue($jobType, $payload = null, $priority = CronJobType::PRIORITY_NORMAL, $maxAttempts = 10, $scheduledAt = null)
{
$data = [
'job_type' => $jobType,
'status' => CronJobType::STATUS_PENDING,
'priority' => $priority,
'max_attempts' => $maxAttempts,
'scheduled_at' => $scheduledAt ? $scheduledAt : date('Y-m-d H:i:s'),
];
if ($payload !== null) {
$data['payload'] = json_encode($payload);
}
$this->db->insert('pp_cron_jobs', $data);
$id = $this->db->id();
return $id ? (int) $id : null;
}
/**
* Atomowe pobranie następnych zadań do przetworzenia.
*
* Uwaga: SELECT + UPDATE nie jest w pełni atomowe bez transakcji.
* Po UPDATE re-SELECT potwierdza, które joby zostały faktycznie przejęte
* (chroni przed race condition przy wielu workerach).
*
* @param int $limit
* @return array
*/
public function fetchNext($limit = 5)
{
$now = date('Y-m-d H:i:s');
$jobs = $this->db->select('pp_cron_jobs', '*', [
'status' => CronJobType::STATUS_PENDING,
'scheduled_at[<=]' => $now,
'ORDER' => ['priority' => 'ASC', 'scheduled_at' => 'ASC'],
'LIMIT' => $limit,
]);
if (empty($jobs)) {
return [];
}
$ids = array_column($jobs, 'id');
$this->db->update('pp_cron_jobs', [
'status' => CronJobType::STATUS_PROCESSING,
'started_at' => $now,
'attempts[+]' => 1,
], [
'id' => $ids,
'status' => CronJobType::STATUS_PENDING,
]);
// Re-SELECT: potwierdź, które joby zostały faktycznie przejęte
$claimed = $this->db->select('pp_cron_jobs', '*', [
'id' => $ids,
'status' => CronJobType::STATUS_PROCESSING,
'started_at' => $now,
]);
if (empty($claimed)) {
return [];
}
foreach ($claimed as &$job) {
if ($job['payload'] !== null) {
$job['payload'] = json_decode($job['payload'], true);
}
}
return $claimed;
}
/**
* Oznacz zadanie jako zakończone
*
* @param int $jobId
* @param mixed $result
*/
public function markCompleted($jobId, $result = null)
{
$data = [
'status' => CronJobType::STATUS_COMPLETED,
'completed_at' => date('Y-m-d H:i:s'),
];
if ($result !== null) {
$data['result'] = json_encode($result);
}
$this->db->update('pp_cron_jobs', $data, ['id' => $jobId]);
}
/**
* Oznacz zadanie jako nieudane z backoffem
*
* @param int $jobId
* @param string $error
* @param int $attempt Numer próby (do obliczenia backoffu)
*/
public function markFailed($jobId, $error, $attempt = 1)
{
$job = $this->db->get('pp_cron_jobs', ['max_attempts', 'attempts'], ['id' => $jobId]);
$attempts = $job ? (int) $job['attempts'] : $attempt;
$maxAttempts = $job ? (int) $job['max_attempts'] : 10;
if ($attempts >= $maxAttempts) {
// Przekroczono limit prób — trwale failed
$this->db->update('pp_cron_jobs', [
'status' => CronJobType::STATUS_FAILED,
'last_error' => mb_substr($error, 0, 500),
'completed_at' => date('Y-m-d H:i:s'),
], ['id' => $jobId]);
} else {
// Wróć do pending z backoffem
$backoff = CronJobType::calculateBackoff($attempts);
$nextRun = date('Y-m-d H:i:s', time() + $backoff);
$this->db->update('pp_cron_jobs', [
'status' => CronJobType::STATUS_PENDING,
'last_error' => mb_substr($error, 0, 500),
'scheduled_at' => $nextRun,
], ['id' => $jobId]);
}
}
/**
* Sprawdź czy istnieje pending job danego typu z opcjonalnym payload match
*
* @param string $jobType
* @param array|null $payloadMatch
* @return bool
*/
public function hasPendingJob($jobType, $payloadMatch = null)
{
$where = [
'job_type' => $jobType,
'status' => [CronJobType::STATUS_PENDING, CronJobType::STATUS_PROCESSING],
];
if ($payloadMatch !== null) {
$where['payload'] = json_encode($payloadMatch);
}
$count = $this->db->count('pp_cron_jobs', $where);
return $count > 0;
}
/**
* Wyczyść stare zakończone zadania
*
* @param int $olderThanDays
*/
public function cleanup($olderThanDays = 30)
{
$cutoff = date('Y-m-d H:i:s', time() - ($olderThanDays * 86400));
$this->db->delete('pp_cron_jobs', [
'status' => [CronJobType::STATUS_COMPLETED, CronJobType::STATUS_FAILED, CronJobType::STATUS_CANCELLED],
'updated_at[<]' => $cutoff,
]);
}
/**
* Odzyskaj zablokowane zadania (stuck w processing)
*
* @param int $olderThanMinutes
*/
public function recoverStuck($olderThanMinutes = 30)
{
$cutoff = date('Y-m-d H:i:s', time() - ($olderThanMinutes * 60));
$this->db->update('pp_cron_jobs', [
'status' => CronJobType::STATUS_PENDING,
'started_at' => null,
], [
'status' => CronJobType::STATUS_PROCESSING,
'started_at[<]' => $cutoff,
]);
}
/**
* Pobierz harmonogramy gotowe do uruchomienia
*
* @return array
*/
public function getDueSchedules()
{
$now = date('Y-m-d H:i:s');
return $this->db->select('pp_cron_schedules', '*', [
'enabled' => 1,
'OR' => [
'next_run_at' => null,
'next_run_at[<=]' => $now,
],
'ORDER' => ['priority' => 'ASC'],
]);
}
/**
* Aktualizuj harmonogram po uruchomieniu
*
* @param int $scheduleId
* @param int $intervalSeconds
*/
public function touchSchedule($scheduleId, $intervalSeconds)
{
$now = date('Y-m-d H:i:s');
$nextRun = date('Y-m-d H:i:s', time() + $intervalSeconds);
$this->db->update('pp_cron_schedules', [
'last_run_at' => $now,
'next_run_at' => $nextRun,
], ['id' => $scheduleId]);
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace Domain\CronJob;
class CronJobType
{
// Job types
const APILO_TOKEN_KEEPALIVE = 'apilo_token_keepalive';
const APILO_SEND_ORDER = 'apilo_send_order';
const APILO_SYNC_PAYMENT = 'apilo_sync_payment';
const APILO_SYNC_STATUS = 'apilo_sync_status';
const APILO_PRODUCT_SYNC = 'apilo_product_sync';
const APILO_PRICELIST_SYNC = 'apilo_pricelist_sync';
const APILO_STATUS_POLL = 'apilo_status_poll';
const PRICE_HISTORY = 'price_history';
const ORDER_ANALYSIS = 'order_analysis';
const TRUSTMATE_INVITATION = 'trustmate_invitation';
const GOOGLE_XML_FEED = 'google_xml_feed';
// Priorities (lower = more important)
const PRIORITY_CRITICAL = 10;
const PRIORITY_SEND_ORDER = 40; // apilo_send_order musi być PRZED sync payment/status
const PRIORITY_HIGH = 50;
const PRIORITY_NORMAL = 100;
const PRIORITY_LOW = 200;
// Statuses
const STATUS_PENDING = 'pending';
const STATUS_PROCESSING = 'processing';
const STATUS_COMPLETED = 'completed';
const STATUS_FAILED = 'failed';
const STATUS_CANCELLED = 'cancelled';
// Backoff
const BASE_BACKOFF_SECONDS = 60;
const MAX_BACKOFF_SECONDS = 3600;
/**
* @return string[]
*/
public static function allTypes()
{
return [
self::APILO_TOKEN_KEEPALIVE,
self::APILO_SEND_ORDER,
self::APILO_SYNC_PAYMENT,
self::APILO_SYNC_STATUS,
self::APILO_PRODUCT_SYNC,
self::APILO_PRICELIST_SYNC,
self::APILO_STATUS_POLL,
self::PRICE_HISTORY,
self::ORDER_ANALYSIS,
self::TRUSTMATE_INVITATION,
self::GOOGLE_XML_FEED,
];
}
/**
* @return string[]
*/
public static function allStatuses()
{
return [
self::STATUS_PENDING,
self::STATUS_PROCESSING,
self::STATUS_COMPLETED,
self::STATUS_FAILED,
self::STATUS_CANCELLED,
];
}
/**
* @param int $attempt
* @return int
*/
public static function calculateBackoff($attempt)
{
$backoff = self::BASE_BACKOFF_SECONDS * pow(2, $attempt - 1);
return min($backoff, self::MAX_BACKOFF_SECONDS);
}
}

View File

@@ -7,17 +7,21 @@ class OrderAdminService
private $productRepo;
private $settingsRepo;
private $transportRepo;
/** @var \Domain\CronJob\CronJobRepository|null */
private $cronJobRepo;
public function __construct(
OrderRepository $orders,
$productRepo = null,
$settingsRepo = null,
$transportRepo = null
$transportRepo = null,
$cronJobRepo = null
) {
$this->orders = $orders;
$this->productRepo = $productRepo;
$this->settingsRepo = $settingsRepo;
$this->transportRepo = $transportRepo;
$this->cronJobRepo = $cronJobRepo;
}
public function details(int $orderId): array
@@ -519,92 +523,6 @@ class OrderAdminService
return $this->orders->deleteOrder($orderId);
}
// =========================================================================
// Apilo sync queue (migrated from \shop\Order)
// =========================================================================
private const APILO_SYNC_QUEUE_FILE = '/temp/apilo-sync-queue.json';
public function processApiloSyncQueue(int $limit = 10): int
{
$queue = self::loadApiloSyncQueue();
if (!\Shared\Helpers\Helpers::is_array_fix($queue)) {
return 0;
}
$processed = 0;
foreach ($queue as $key => $task)
{
if ($processed >= $limit) {
break;
}
$order_id = (int)($task['order_id'] ?? 0);
if ($order_id <= 0) {
unset($queue[$key]);
continue;
}
$order = $this->orders->findRawById($order_id);
if (!$order) {
unset($queue[$key]);
continue;
}
$error = '';
$sync_failed = false;
$max_attempts = 50; // ~8h przy cronie co 10 min
// Zamówienie jeszcze nie wysłane do Apilo — czekaj na crona
if (!(int)$order['apilo_order_id']) {
$attempts = (int)($task['attempts'] ?? 0) + 1;
if ($attempts >= $max_attempts) {
// Przekroczono limit prób — porzuć task
unset($queue[$key]);
} else {
$task['attempts'] = $attempts;
$task['last_error'] = 'awaiting_apilo_order';
$task['updated_at'] = date('Y-m-d H:i:s');
$queue[$key] = $task;
}
$processed++;
continue;
}
$payment_pending = !empty($task['payment']) && (int)$order['paid'] === 1;
if ($payment_pending) {
if (!$this->syncApiloPayment($order)) {
$sync_failed = true;
$error = 'payment_sync_failed';
}
}
$status_pending = isset($task['status']) && $task['status'] !== null && $task['status'] !== '';
if (!$sync_failed && $status_pending) {
if (!$this->syncApiloStatus($order, (int)$task['status'])) {
$sync_failed = true;
$error = 'status_sync_failed';
}
}
if ($sync_failed) {
$task['attempts'] = (int)($task['attempts'] ?? 0) + 1;
$task['last_error'] = $error;
$task['updated_at'] = date('Y-m-d H:i:s');
$queue[$key] = $task;
} else {
unset($queue[$key]);
}
$processed++;
}
self::saveApiloSyncQueue($queue);
return $processed;
}
// =========================================================================
// Private: email
// =========================================================================
@@ -689,7 +607,7 @@ class OrderAdminService
'Brak apilo_order_id — płatność zakolejkowana do sync',
['apilo_order_id' => $order['apilo_order_id'] ?? null]
);
self::queueApiloSync((int)$order['id'], true, null, 'awaiting_apilo_order');
$this->queueApiloSync((int)$order['id'], true, null, 'awaiting_apilo_order');
} elseif (!$this->syncApiloPayment($order)) {
\Domain\Integrations\ApiloLogger::log(
$db,
@@ -698,7 +616,7 @@ class OrderAdminService
'Sync płatności nieudany — zakolejkowano ponowną próbę',
['apilo_order_id' => $order['apilo_order_id']]
);
self::queueApiloSync((int)$order['id'], true, null, 'payment_sync_failed');
$this->queueApiloSync((int)$order['id'], true, null, 'payment_sync_failed');
}
}
@@ -739,7 +657,7 @@ class OrderAdminService
'Brak apilo_order_id — status zakolejkowany do sync',
['apilo_order_id' => $order['apilo_order_id'] ?? null, 'target_status' => $status]
);
self::queueApiloSync((int)$order['id'], false, $status, 'awaiting_apilo_order');
$this->queueApiloSync((int)$order['id'], false, $status, 'awaiting_apilo_order');
} elseif (!$this->syncApiloStatus($order, $status)) {
\Domain\Integrations\ApiloLogger::log(
$db,
@@ -748,11 +666,11 @@ class OrderAdminService
'Sync statusu nieudany — zakolejkowano ponowną próbę',
['apilo_order_id' => $order['apilo_order_id'], 'target_status' => $status]
);
self::queueApiloSync((int)$order['id'], false, $status, 'status_sync_failed');
$this->queueApiloSync((int)$order['id'], false, $status, 'status_sync_failed');
}
}
private function syncApiloPayment(array $order): bool
public function syncApiloPayment(array $order): bool
{
global $config;
@@ -819,7 +737,7 @@ class OrderAdminService
return true;
}
private function syncApiloStatus(array $order, int $status): bool
public function syncApiloStatus(array $order, int $status): bool
{
global $config;
@@ -882,59 +800,42 @@ class OrderAdminService
}
// =========================================================================
// Private: Apilo sync queue file helpers
// Private: Apilo sync queue (DB-based via CronJobRepository)
// =========================================================================
private static function queueApiloSync(int $order_id, bool $payment, ?int $status, string $error): void
private function queueApiloSync(int $order_id, bool $payment, ?int $status, string $error): void
{
if ($order_id <= 0) return;
$queue = self::loadApiloSyncQueue();
$key = (string)$order_id;
$row = is_array($queue[$key] ?? null) ? $queue[$key] : [];
if ($this->cronJobRepo === null) return;
if ($payment) {
$jobType = \Domain\CronJob\CronJobType::APILO_SYNC_PAYMENT;
$payload = ['order_id' => $order_id];
if (!$this->cronJobRepo->hasPendingJob($jobType, $payload)) {
$this->cronJobRepo->enqueue(
$jobType,
$payload,
\Domain\CronJob\CronJobType::PRIORITY_HIGH,
50
);
}
}
$row['order_id'] = $order_id;
$row['payment'] = !empty($row['payment']) || $payment ? 1 : 0;
if ($status !== null) {
$row['status'] = $status;
$jobType = \Domain\CronJob\CronJobType::APILO_SYNC_STATUS;
$payload = ['order_id' => $order_id, 'status' => $status];
if (!$this->cronJobRepo->hasPendingJob($jobType, $payload)) {
$this->cronJobRepo->enqueue(
$jobType,
$payload,
\Domain\CronJob\CronJobType::PRIORITY_HIGH,
50
);
}
}
$row['attempts'] = (int)($row['attempts'] ?? 0) + 1;
$row['last_error'] = $error;
$row['updated_at'] = date('Y-m-d H:i:s');
$queue[$key] = $row;
self::saveApiloSyncQueue($queue);
}
private static function apiloSyncQueuePath(): string
{
return dirname(__DIR__, 2) . self::APILO_SYNC_QUEUE_FILE;
}
private static function loadApiloSyncQueue(): array
{
$path = self::apiloSyncQueuePath();
if (!file_exists($path)) return [];
$content = file_get_contents($path);
if (!$content) return [];
$decoded = json_decode($content, true);
if (!is_array($decoded)) return [];
return $decoded;
}
private static function saveApiloSyncQueue(array $queue): void
{
$path = self::apiloSyncQueuePath();
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
}
file_put_contents($path, json_encode($queue, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
}
private static function appendApiloLog(string $message): void

View File

@@ -134,7 +134,11 @@ class PagesRepository
return false;
}
return (bool)$this->db->delete('pp_pages', ['id' => $pageId]);
$deleted = (bool)$this->db->delete('pp_pages', ['id' => $pageId]);
if ($deleted) {
$this->db->delete('pp_routes', ['page_id' => $pageId]);
}
return $deleted;
}
/**

View File

@@ -172,13 +172,19 @@ class UpdateRepository
foreach ( $manifest['sql'] as $query ) {
$query = trim( $query );
if ( $query !== '' ) {
if ( $query === '' || strpos( $query, '--' ) === 0 ) {
continue;
}
try {
if ( $this->db->query( $query ) ) {
$success++;
} else {
$errors++;
$log[] = '[WARNING] Błąd SQL: ' . $query;
}
} catch ( \Exception $e ) {
$errors++;
$log[] = '[WARNING] Wyjątek SQL: ' . $e->getMessage() . ' | Query: ' . substr( $query, 0, 200 );
}
}

View File

@@ -425,42 +425,206 @@ class Helpers
$site_map .= '<priority>1</priority>' . PHP_EOL;
$site_map .= '</url>' . PHP_EOL;
$htaccess_data = file_get_contents( $dir . 'libraries/htaccess.conf' );
$htaccess_data = str_replace( '{PAGE}', $url, $htaccess_data );
//
// SYSTEM ROUTES — delete all and reinsert
//
$mdb->delete( 'pp_routes', [ 'type' => 'system' ] );
$results = $mdb -> select( 'pp_langs', [ 'id' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
// Static system routes (hardcoded, never change)
$systemRoutes = [
// Wyszukiwarka
[ 'pattern' => '^wyszukiwarka/([^/]+)/([0-9]+)$', 'destination' => 'index.php?module=search&action=search_results&query=$1&bs=$2' ],
[ 'pattern' => '^wyszukiwarka/([^/]+)$', 'destination' => 'index.php?module=search&action=search_results&query=$1&bs=1' ],
// Zamowienia
[ 'pattern' => '^zamowienie/([a-zA-Z0-9-]+)$', 'destination' => 'index.php?module=shop_order&action=order_details&order_hash=$1' ],
[ 'pattern' => '^potwierdzenie-platnosci/([a-zA-Z0-9-]+)$', 'destination' => 'index.php?module=shop_order&action=payment_confirmation&order_hash=$1' ],
// Platnosci
[ 'pattern' => '^tpay-status$', 'destination' => 'index.php?module=shop_order&action=payment_status_tpay' ],
[ 'pattern' => '^platnosc-status$', 'destination' => 'index.php?module=shop_order&action=payment_status_hotpay' ],
[ 'pattern' => '^przelewy24-status$', 'destination' => 'index.php?module=shop_order&action=payment_status_przelewy24pl' ],
// Koszyk
[ 'pattern' => '^koszyk$', 'destination' => 'index.php?module=shop_basket&action=main_view' ],
[ 'pattern' => '^koszyk-podsumowanie$', 'destination' => 'index.php?module=shop_basket&action=summary_view' ],
[ 'pattern' => '^zloz-zamowienie$', 'destination' => 'index.php?module=shop_basket&action=basket_save' ],
// Klient
[ 'pattern' => '^rejestracja$', 'destination' => 'index.php?module=shop_client&action=register_form' ],
[ 'pattern' => '^logowanie$', 'destination' => 'index.php?module=shop_client&action=login_form' ],
[ 'pattern' => '^wylogowanie$', 'destination' => 'index.php?module=shop_client&action=logout' ],
[ 'pattern' => '^odzyskiwanie-hasla$', 'destination' => 'index.php?module=shop_client&action=recover_password' ],
[ 'pattern' => '^panel-klienta/zamowienia$', 'destination' => 'index.php?module=shop_client&action=client_orders' ],
[ 'pattern' => '^panel-klienta/adresy$', 'destination' => 'index.php?module=shop_client&action=client_addresses' ],
[ 'pattern' => '^panel-klienta/nowy-adres$', 'destination' => 'index.php?module=shop_client&action=address_edit' ],
[ 'pattern' => '^panel-klienta/edytuj-adres/([0-9]+)$', 'destination' => 'index.php?module=shop_client&action=address_edit&id=$1' ],
[ 'pattern' => '^panel-klienta/usun-adres/([0-9]+)$', 'destination' => 'index.php?module=shop_client&action=address_delete&id=$1' ],
// Newsletter
[ 'pattern' => '^newsletter/signin$', 'destination' => 'index.php?module=newsletter&action=signin' ],
[ 'pattern' => '^newsletter/confirm/hash=(.+)$', 'destination' => 'index.php?module=newsletter&action=confirm&hash=$1' ],
[ 'pattern' => '^newsletter/unsubscribe/hash=(.+)$', 'destination' => 'index.php?module=newsletter&action=unsubscribe&hash=$1' ],
// Moduły AJAX (shopBasket, shopClient, shopProduct, shopCoupon, search)
[ 'pattern' => '^shopBasket/([^/]+)/(.+)$', 'destination' => 'index.php?module=shopBasket&action=$1&$2' ],
[ 'pattern' => '^shopBasket/([^/]+)$', 'destination' => 'index.php?module=shopBasket&action=$1' ],
[ 'pattern' => '^shopClient/([^/]+)/(.+)$', 'destination' => 'index.php?module=shopClient&action=$1&$2' ],
[ 'pattern' => '^shopClient/([^/]+)$', 'destination' => 'index.php?module=shopClient&action=$1' ],
[ 'pattern' => '^shopProduct/([^/]+)/(.+)$', 'destination' => 'index.php?module=shopProduct&action=$1&$2' ],
[ 'pattern' => '^shopProduct/([^/]+)$', 'destination' => 'index.php?module=shopProduct&action=$1' ],
[ 'pattern' => '^shopCoupon/([^/]+)/(.+)$', 'destination' => 'index.php?module=shopCoupon&action=$1&$2' ],
[ 'pattern' => '^shopCoupon/([^/]+)$', 'destination' => 'index.php?module=shopCoupon&action=$1' ],
[ 'pattern' => '^search/([^/]+)/(.+)$', 'destination' => 'index.php?module=search&action=$1&$2' ],
[ 'pattern' => '^search/([^/]+)$', 'destination' => 'index.php?module=search&action=$1' ],
];
foreach ( $systemRoutes as $route )
{
$mdb->insert( 'pp_routes', [
'type' => 'system',
'lang_id' => 0,
'pattern' => $route['pattern'],
'destination' => $route['destination'],
] );
}
// Dynamic system routes — languages
$results = $mdb->select( 'pp_langs', [ 'id' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
if ( is_array( $results ) ) foreach ( $results as $row )
{
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $row['id'] . '/$ index.php?a=change_language&id=' . $row['id'] . ' [L]';
$mdb->insert( 'pp_routes', [
'type' => 'system',
'lang_id' => 0,
'pattern' => '^' . $row['id'] . '$',
'destination' => 'index.php?a=change_language&id=' . $row['id'],
] );
}
//
// INNE
//
$htaccess_data .= PHP_EOL;
$htaccess_data .= 'RewriteRule ^newsletter/signin/$ index.php?module=newsletter&action=signin [L]' . PHP_EOL;
$htaccess_data .= 'RewriteRule ^newsletter/confirm/hash=(.*)$ index.php?module=newsletter&action=confirm&hash=$1 [L]' . PHP_EOL;
$htaccess_data .= 'RewriteRule ^newsletter/unsubscribe/hash=(.*)$ index.php?module=newsletter&action=unsubscribe&hash=$1 [L]' . PHP_EOL;
//
// PRODUCENCI
//
// Dynamic system routes — producenci
$categoryDefaultLayoutId = ( new \Domain\Layouts\LayoutsRepository( $mdb ) )->categoryDefaultLayoutId();
$htaccess_data .= 'RewriteRule ^producenci$ index.php?module=shop_producer&action=list&layout_id=' . $categoryDefaultLayoutId . '&%{QUERY_STRING} [L]' . PHP_EOL;
$rows = $mdb -> select( 'pp_shop_producer', '*', [ 'status' => 1 ] );
$mdb->insert( 'pp_routes', [
'type' => 'system',
'lang_id' => 0,
'pattern' => '^producenci$',
'destination' => 'index.php?module=shop_producer&action=list&layout_id=' . $categoryDefaultLayoutId,
] );
$rows = $mdb->select( 'pp_shop_producer', '*', [ 'status' => 1 ] );
if ( self::is_array_fix( $rows ) ) foreach ( $rows as $row )
{
$htaccess_data .= 'RewriteRule ^producent/' . self::seo( $row['name'] ) . '$ index.php?module=shop_producer&action=products&producer_id=' . $row['id'] . '&layout_id=' . $categoryDefaultLayoutId . '&%{QUERY_STRING} [L]' . PHP_EOL;
$htaccess_data .= 'RewriteRule ^producent/' . self::seo( $row['name'] ) . '/([0-9]+)$ index.php?module=shop_producer&action=products&producer_id=' . $row['id'] . '&layout_id=' . $categoryDefaultLayoutId . '&bs=$1&%{QUERY_STRING} [L]' . PHP_EOL;
$mdb->insert( 'pp_routes', [
'type' => 'system',
'lang_id' => 0,
'pattern' => '^producent/' . self::seo( $row['name'] ) . '$',
'destination' => 'index.php?module=shop_producer&action=products&producer_id=' . $row['id'] . '&layout_id=' . $categoryDefaultLayoutId,
] );
$mdb->insert( 'pp_routes', [
'type' => 'system',
'lang_id' => 0,
'pattern' => '^producent/' . self::seo( $row['name'] ) . '/([0-9]+)$',
'destination' => 'index.php?module=shop_producer&action=products&producer_id=' . $row['id'] . '&layout_id=' . $categoryDefaultLayoutId . '&bs=$1',
] );
}
$results = $mdb -> select( 'pp_langs', [ 'id', 'start' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
//
// HTACCESS — generuj z PHP (bez szablonu htaccess.conf)
//
$htaccess_data = 'RewriteEngine On' . PHP_EOL;
$htaccess_data .= 'RewriteBase /' . PHP_EOL;
$htaccess_data .= 'Options +FollowSymlinks' . PHP_EOL;
$htaccess_data .= 'Options -Indexes' . PHP_EOL;
$htaccess_data .= PHP_EOL;
$htaccess_data .= '# Przekierowanie z www na bez www i z http na https w jednym kroku' . PHP_EOL;
$htaccess_data .= 'RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]' . PHP_EOL;
$htaccess_data .= 'RewriteRule ^ https://%1%{REQUEST_URI} [R=301,L]' . PHP_EOL;
$htaccess_data .= PHP_EOL;
$htaccess_data .= '# Przekierowanie z http na https, jesli nie zawiera www' . PHP_EOL;
$htaccess_data .= 'RewriteCond %{HTTPS} off' . PHP_EOL;
$htaccess_data .= 'RewriteCond %{REQUEST_URI} !^/(tpay-status|platnosc-status|przelewy24-status)$ [NC]' . PHP_EOL;
$htaccess_data .= 'RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]' . PHP_EOL;
$htaccess_data .= PHP_EOL;
$htaccess_data .= '# Usuwanie koncowego slasha dla niekatalogów' . PHP_EOL;
$htaccess_data .= 'RewriteCond %{REQUEST_FILENAME} !-d' . PHP_EOL;
$htaccess_data .= 'RewriteCond %{REQUEST_URI} !^/admin/.*$ [NC]' . PHP_EOL;
$htaccess_data .= 'RewriteCond %{REQUEST_URI} (.+)/$' . PHP_EOL;
$htaccess_data .= 'RewriteRule ^ %1 [R=301,L]' . PHP_EOL;
$htaccess_data .= PHP_EOL;
$htaccess_data .= 'RewriteCond %{REQUEST_URI} !^(.*)/libraries/(.*) [NC]' . PHP_EOL;
$htaccess_data .= 'RewriteCond %{REQUEST_URI} !^(.*)/layout/(.*) [NC]' . PHP_EOL;
$htaccess_data .= 'RewriteRule ^admin/([^/]*)/([^/]*)/(.*)$ admin/index.php?module=$1&action=$2&$3 [L]' . PHP_EOL;
$htaccess_data .= PHP_EOL;
$htaccess_data .= 'RewriteRule ^admin/$ admin/index.php [L]' . PHP_EOL;
$htaccess_data .= PHP_EOL;
$htaccess_data .= 'RewriteRule ^thumb/([0-9]*)/([0-9]*)/(.*)$ /libraries/thumb.php?img=$3&w=$1&h=$2 [L]' . PHP_EOL;
$htaccess_data .= PHP_EOL;
$htaccess_data .= 'RewriteCond %{THE_REQUEST} ^[A-Z]{3,9}\ /index.php' . PHP_EOL;
$htaccess_data .= 'RewriteRule ^ /%1 [R=301,L]' . PHP_EOL;
/* cache block */
if ( $settings['htaccess_cache'] )
{
$htaccess_data .= '<IfModule mod_deflate.c>' . PHP_EOL
. 'AddOutputFilterByType DEFLATE text/html text/plain text/xml application/xml application/xhtml+xml text/css text/javascript application/javascript application/x-javascript' . PHP_EOL
. '</IfModule>' . PHP_EOL
. '<IfModule mod_headers.c>' . PHP_EOL
. 'Header set Access-Control-Allow-Origin "*"' . PHP_EOL
. '</IfModule>' . PHP_EOL
. '<IfModule mod_expires.c>' . PHP_EOL
. 'ExpiresActive on' . PHP_EOL
. 'ExpiresDefault "access plus 1 month"' . PHP_EOL
. 'ExpiresByType text/css "access plus 1 year"' . PHP_EOL
. 'ExpiresByType application/json "access plus 0 seconds"' . PHP_EOL
. 'ExpiresByType application/xml "access plus 0 seconds"' . PHP_EOL
. 'ExpiresByType text/xml "access plus 0 seconds"' . PHP_EOL
. 'ExpiresByType image/x-icon "access plus 1 week"' . PHP_EOL
. 'ExpiresByType text/x-component "access plus 1 month"' . PHP_EOL
. 'ExpiresByType text/html "access plus 0 seconds"' . PHP_EOL
. 'ExpiresByType application/javascript "access plus 1 year"' . PHP_EOL
. 'ExpiresByType application/x-web-app-manifest+json "access plus 0 seconds"' . PHP_EOL
. 'ExpiresByType text/cache-manifest "access plus 0 seconds"' . PHP_EOL
. 'ExpiresByType audio/ogg "access plus 1 month"' . PHP_EOL
. 'ExpiresByType image/gif "access plus 1 month"' . PHP_EOL
. 'ExpiresByType image/jpeg "access plus 1 month"' . PHP_EOL
. 'ExpiresByType image/png "access plus 1 month"' . PHP_EOL
. 'ExpiresByType video/mp4 "access plus 1 month"' . PHP_EOL
. 'ExpiresByType video/ogg "access plus 1 month"' . PHP_EOL
. 'ExpiresByType video/webm "access plus 1 month"' . PHP_EOL
. 'ExpiresByType application/atom+xml "access plus 1 hour"' . PHP_EOL
. 'ExpiresByType application/rss+xml "access plus 1 hour"' . PHP_EOL
. 'ExpiresByType application/font-woff "access plus 1 month"' . PHP_EOL
. 'ExpiresByType application/vnd.ms-fontobject "access plus 1 month"' . PHP_EOL
. 'ExpiresByType application/x-font-ttf "access plus 1 month"' . PHP_EOL
. 'ExpiresByType font/opentype "access plus 1 month"' . PHP_EOL
. 'ExpiresByType image/svg+xml "access plus 1 month"' . PHP_EOL
. '</IfModule>' . PHP_EOL;
}
else
{
$htaccess_data .= '<IfModule mod_headers.c>' . PHP_EOL
. 'Header set Cache-Control "no-cache, no-store, must-revalidate"' . PHP_EOL
. 'Header set Pragma "no-cache"' . PHP_EOL
. 'Header set Expires 0' . PHP_EOL
. '</IfModule>' . PHP_EOL;
}
$htaccess_data .= '<Files *.conf>' . PHP_EOL;
$htaccess_data .= ' Order Deny,Allow' . PHP_EOL;
$htaccess_data .= ' Deny from all' . PHP_EOL;
$htaccess_data .= '</Files>' . PHP_EOL;
$htaccess_data .= '<Files *.log>' . PHP_EOL;
$htaccess_data .= ' Order Deny,Allow' . PHP_EOL;
$htaccess_data .= ' Deny from all' . PHP_EOL;
$htaccess_data .= '</Files>' . PHP_EOL;
$htaccess_data .= '<Files *.ini>' . PHP_EOL;
$htaccess_data .= ' Order Deny,Allow' . PHP_EOL;
$htaccess_data .= ' Deny from all' . PHP_EOL;
$htaccess_data .= '</Files>' . PHP_EOL;
//
// KATEGORIE — sitemap + pp_routes (bez zmian)
//
$results = $mdb->select( 'pp_langs', [ 'id', 'start' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
if ( is_array( $results ) ) foreach ( $results as $row )
{
!$row['start'] ? $language_link = $row['id'] . '/' : $language_link = '';
$results2 = $mdb -> select( 'pp_shop_categories_langs', [ '[><]pp_shop_categories' => [ 'category_id' => 'id' ] ], [ 'seo_link', 'title', 'category_id' ], [ 'lang_id' => $row['id'], 'ORDER' => [ 'o' => 'ASC' ] ] );
$results2 = $mdb->select( 'pp_shop_categories_langs', [ '[><]pp_shop_categories' => [ 'category_id' => 'id' ] ], [ 'seo_link', 'title', 'category_id' ], [ 'lang_id' => $row['id'], 'ORDER' => [ 'o' => 'ASC' ] ] );
if ( is_array( $results2 ) ) foreach ( $results2 as $row2 )
{
if ( $row2['title'] )
@@ -475,35 +639,42 @@ class Helpers
$site_map .= '<priority>1</priority>' . PHP_EOL;
$site_map .= '</url>' . PHP_EOL;
if ( $row2['seo_link'] )
{
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . self::seo( $row2['seo_link'] ) . '$ index.php?category=' . $row2['category_id'] . '&lang=' . $row['id'] . '&%{QUERY_STRING} [L]';
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . self::seo( $row2['seo_link'] ) . '/([0-9]+)$ index.php?category=' . $row2['category_id'] . '&lang=' . $row['id'] . '&bs=$1&%{QUERY_STRING} [L]';
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . self::seo( $row2['seo_link'] ) . '/1$ ' . $language_link . self::seo( $row2['seo_link'] ) . ' [R=301,L]';
}
else
{
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . 'k-' . $row2['category_id'] . '-' . self::seo( $row2['title'] ) . '$ index.php?category=' . $row2['category_id'] . '&lang=' . $row['id'] . '&%{QUERY_STRING} [L]';
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . 'k-' . $row2['category_id'] . '-' . self::seo( $row2['title'] ) . '/([0-9]+)$ index.php?category=' . $row2['category_id'] . '&lang=' . $row['id'] . '&bs=$1&%{QUERY_STRING} [L]';
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . 'k-' . $row2['category_id'] . '-' . self::seo( $row2['title'] ) . '/1$ ' . $language_link . 'k-' . $row2['category_id'] . '-' . self::seo( $row2['title'] ) . ' [R=301,L]';
}
$seoSlug = $row2['seo_link'] ? self::seo( $row2['seo_link'] ) : 'k-' . $row2['category_id'] . '-' . self::seo( $row2['title'] );
$mdb->delete( 'pp_routes', [ 'AND' => [ 'category_id' => $row2['category_id'], 'lang_id' => $row['id'] ] ] );
$mdb->insert( 'pp_routes', [
'category_id' => $row2['category_id'],
'lang_id' => $row['id'],
'pattern' => '^' . $language_link . $seoSlug . '$',
'destination' => 'index.php?category=' . $row2['category_id'] . '&lang=' . $row['id'],
] );
$mdb->insert( 'pp_routes', [
'category_id' => $row2['category_id'],
'lang_id' => $row['id'],
'pattern' => '^' . $language_link . $seoSlug . '/([0-9]+)$',
'destination' => 'index.php?category=' . $row2['category_id'] . '&lang=' . $row['id'] . '&bs=$1',
] );
}
}
}
$results = $mdb -> select( 'pp_langs', [ 'id', 'start' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
//
// PRODUKTY — sitemap + pp_routes (bez zmian)
//
$results = $mdb->select( 'pp_langs', [ 'id', 'start' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
if ( is_array( $results ) )
{
foreach ( $results as $row )
{
!$row['start'] ? $language_link = $row['id'] . '/' : $language_link = '';
$results2 = $mdb -> select( 'pp_shop_products_langs', [ '[><]pp_shop_products' => [ 'product_id' => 'id' ] ], [ 'seo_link', 'name', 'product_id' ], [ 'lang_id' => $row['id'], 'ORDER' => [ 'name' => 'ASC' ] ] );
$results2 = $mdb->select( 'pp_shop_products_langs', [ '[><]pp_shop_products' => [ 'product_id' => 'id' ] ], [ 'seo_link', 'name', 'product_id' ], [ 'lang_id' => $row['id'], 'ORDER' => [ 'name' => 'ASC' ] ] );
if ( is_array( $results2 ) )
{
foreach ( $results2 as $row2 )
{
$mdb -> delete( 'pp_routes', [ 'AND' => [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'] ] ] );
$mdb->delete( 'pp_routes', [ 'AND' => [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'] ] ] );
if ( $row2['name'] )
{
@@ -519,27 +690,13 @@ class Helpers
if ( $row2['seo_link'] )
{
$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' => $pattern, 'destination' => $destination ] );
$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' => $pattern, 'destination' => $destination ] );
$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' ] );
}
else
{
$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' => $pattern, 'destination' => $destination ] );
$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' => $pattern, 'destination' => $destination ] );
$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' ] );
}
}
}
@@ -547,13 +704,16 @@ class Helpers
}
}
$results = $mdb -> select( 'pp_langs', [ 'id', 'start' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
//
// STRONY + ARTYKULY — sitemap + pp_routes (bez zmian)
//
$results = $mdb->select( 'pp_langs', [ 'id', 'start' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
if ( is_array( $results ) )
foreach ( $results as $row )
{
( !$row['start'] and count( $results ) > 1 ) ? $language_link = $row['id'] . '/' : $language_link = '';
$results2 = $mdb -> select( 'pp_pages_langs', [ '[><]pp_pages' => [ 'page_id' => 'id' ] ], [ 'seo_link', 'title', 'page_id', 'noindex', 'start', 'link', 'page_type' ], [ 'lang_id' => $row['id'], 'ORDER' => [ 'start' => 'DESC', 'o' => 'ASC' ] ] );
$results2 = $mdb->select( 'pp_pages_langs', [ '[><]pp_pages' => [ 'page_id' => 'id' ] ], [ 'seo_link', 'title', 'page_id', 'noindex', 'start', 'link', 'page_type' ], [ 'lang_id' => $row['id'], 'ORDER' => [ 'start' => 'DESC', 'o' => 'ASC' ] ] );
if ( is_array( $results2 ) )
foreach ( $results2 as $row2 )
{
@@ -590,44 +750,39 @@ class Helpers
{
$htaccess_data .= PHP_EOL . 'RewriteCond %{REQUEST_URI} ^/s-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . '$';
$htaccess_data .= PHP_EOL . 'RewriteRule ^(.*)$ http://www.' . $url . '/' . $language_link . ' [R=permanent,L]';
$htaccess_data .= PHP_EOL . 'RewriteCond %{REQUEST_URI} ^/s-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . '-1$';
$htaccess_data .= PHP_EOL . 'RewriteRule ^(.*)$ http://www.' . $url . '/' . $language_link . ' [R=permanent,L]';
}
$htaccess_data .= PHP_EOL . 'RewriteRule ^$ index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'] . ' [L]';
}
if ( $row2['seo_link'] )
{
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . self::seo( $row2['seo_link'] ) . '$ index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'] . '&%{QUERY_STRING} [L]';
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . self::seo( $row2['seo_link'] ) . '/([0-9]+)$ index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'] . '&bs=$1&%{QUERY_STRING} [L]';
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . self::seo( $row2['seo_link'] ) . '/1$ ' . $language_link . self::seo( $row2['seo_link'] ) . ' [R=301,L]';
}
else
{
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . 's-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . '$ index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'] . '&%{QUERY_STRING} [L]';
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . 's-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . '/([0-9]+)$ index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'] . '&bs=$1&%{QUERY_STRING} [L]';
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . 's-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . '/1$ ' . $language_link . 's-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . ' [R=301,L]';
}
$seoSlug = $row2['seo_link'] ? self::seo( $row2['seo_link'] ) : 's-' . $row2['page_id'] . '-' . self::seo( $row2['title'] );
$langPrefix = $row2['start'] ? '' : $language_link;
$mdb->delete( 'pp_routes', [ 'AND' => [ 'page_id' => $row2['page_id'], 'lang_id' => $row['id'] ] ] );
$mdb->insert( 'pp_routes', [
'page_id' => $row2['page_id'],
'lang_id' => $row['id'],
'pattern' => '^' . $langPrefix . $seoSlug . '$',
'destination' => 'index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'],
] );
$mdb->insert( 'pp_routes', [
'page_id' => $row2['page_id'],
'lang_id' => $row['id'],
'pattern' => '^' . $langPrefix . $seoSlug . '/([0-9]+)$',
'destination' => 'index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'] . '&bs=$1',
] );
}
}
$results2 = $mdb -> select( 'pp_articles_langs', [ '[><]pp_articles' => [ 'article_id' => 'id' ] ], [ 'seo_link', 'title', 'article_id', 'noindex', 'copy_from' ], [ 'AND' => [ 'status' => 1, 'lang_id' => $row['id'], 'block_direct_access' => 0 ] ] );
$results2 = $mdb->select( 'pp_articles_langs', [ '[><]pp_articles' => [ 'article_id' => 'id' ] ], [ 'seo_link', 'title', 'article_id', 'noindex', 'copy_from' ], [ 'AND' => [ 'status' => 1, 'lang_id' => $row['id'], 'block_direct_access' => 0 ] ] );
if ( is_array( $results2 ) )
foreach ( $results2 as $row2 )
{
if ( $row2['copy_from'] != null )
{
$results_tmp = $mdb -> get( 'pp_articles_langs', [
'seo_link',
'title'
], [
'AND' => [
'article_id' => $row2['article_id'],
'lang_id' => $row2['copy_from']
]
] );
$results_tmp = $mdb->get( 'pp_articles_langs', [ 'seo_link', 'title' ], [ 'AND' => [ 'article_id' => $row2['article_id'], 'lang_id' => $row2['copy_from'] ] ] );
$row2['seo_link'] = $results_tmp['seo_link'];
$row2['title'] = $results_tmp['title'];
}
@@ -650,81 +805,52 @@ class Helpers
$robots .= 'Disallow: /' . $row2['seo_link'] . PHP_EOL;
}
$mdb->delete( 'pp_routes', [ 'AND' => [ 'article_id' => $row2['article_id'], 'lang_id' => $row['id'] ] ] );
if ( $row2['seo_link'] )
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . self::seo( $row2['seo_link'] ) . '$ index.php?article=' . $row2['article_id'] . '&lang=' . $row['id'] . '&%{QUERY_STRING} [L]';
{
$mdb->insert( 'pp_routes', [
'article_id' => $row2['article_id'],
'lang_id' => $row['id'],
'pattern' => '^' . $language_link . self::seo( $row2['seo_link'] ) . '$',
'destination' => 'index.php?article=' . $row2['article_id'] . '&lang=' . $row['id'],
] );
}
else if ( $row2['title'] != null )
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . 'a-' . $row2['article_id'] . '-' . self::seo( $row2['title'] ) . '$ index.php?article=' . $row2['article_id'] . '&lang=' . $row['id'] . '&%{QUERY_STRING} [L]';
{
$mdb->insert( 'pp_routes', [
'article_id' => $row2['article_id'],
'lang_id' => $row['id'],
'pattern' => '^' . $language_link . 'a-' . $row2['article_id'] . '-' . self::seo( $row2['title'] ) . '$',
'destination' => 'index.php?article=' . $row2['article_id'] . '&lang=' . $row['id'],
] );
}
}
}
$results = $mdb -> get( 'pp_settings', 'value', [ 'param' => 'htaccess' ] );
// Invalidacja cache tras
try {
( new \Shared\Cache\CacheHandler() )->delete( 'pp_routes:all' );
} catch ( \Exception $e ) {
// Redis niedostepny — ignorujemy
}
$results = $mdb->get( 'pp_settings', 'value', [ 'param' => 'htaccess' ] );
if ( $results )
$htaccess_data .= PHP_EOL . $results;
$results = $mdb -> get( 'pp_settings', 'value', [ 'param' => 'robots' ] );
$results = $mdb->get( 'pp_settings', 'value', [ 'param' => 'robots' ] );
if ( $results )
$robots .= PHP_EOL . $results;
$robots .= PHP_EOL . $results;
$site_map .= '</urlset>';
/* cache */
if ( $settings['htaccess_cache'] )
{
$htaccess_data = str_replace( '{HTACCESS_CACHE}',
'<IfModule mod_deflate.c>' . PHP_EOL
. 'AddOutputFilterByType DEFLATE text/html text/plain text/xml application/xml application/xhtml+xml text/css text/javascript application/javascript application/x-javascript' . PHP_EOL
. '</IfModule>' . PHP_EOL
. '<IfModule mod_headers.c>' . PHP_EOL
. 'Header set Access-Control-Allow-Origin "*"' . PHP_EOL
. '</IfModule>' . PHP_EOL
. '<IfModule mod_expires.c>' . PHP_EOL
. 'ExpiresActive on' . PHP_EOL
. 'ExpiresDefault "access plus 1 month"' . PHP_EOL
. 'ExpiresByType text/css "access plus 1 year"' . PHP_EOL
. 'ExpiresByType application/json "access plus 0 seconds"' . PHP_EOL
. 'ExpiresByType application/xml "access plus 0 seconds"' . PHP_EOL
. 'ExpiresByType text/xml "access plus 0 seconds"' . PHP_EOL
. 'ExpiresByType image/x-icon "access plus 1 week"' . PHP_EOL
. 'ExpiresByType text/x-component "access plus 1 month"' . PHP_EOL
. 'ExpiresByType text/html "access plus 0 seconds"' . PHP_EOL
. 'ExpiresByType application/javascript "access plus 1 year"' . PHP_EOL
. 'ExpiresByType application/x-web-app-manifest+json "access plus 0 seconds"' . PHP_EOL
. 'ExpiresByType text/cache-manifest "access plus 0 seconds"' . PHP_EOL
. 'ExpiresByType audio/ogg "access plus 1 month"' . PHP_EOL
. 'ExpiresByType image/gif "access plus 1 month"' . PHP_EOL
. 'ExpiresByType image/jpeg "access plus 1 month"' . PHP_EOL
. 'ExpiresByType image/png "access plus 1 month"' . PHP_EOL
. 'ExpiresByType video/mp4 "access plus 1 month"' . PHP_EOL
. 'ExpiresByType video/ogg "access plus 1 month"' . PHP_EOL
. 'ExpiresByType video/webm "access plus 1 month"' . PHP_EOL
. 'ExpiresByType application/atom+xml "access plus 1 hour"' . PHP_EOL
. 'ExpiresByType application/rss+xml "access plus 1 hour"' . PHP_EOL
. 'ExpiresByType application/font-woff "access plus 1 month"' . PHP_EOL
. 'ExpiresByType application/vnd.ms-fontobject "access plus 1 month"' . PHP_EOL
. 'ExpiresByType application/x-font-ttf "access plus 1 month"' . PHP_EOL
. 'ExpiresByType font/opentype "access plus 1 month"' . PHP_EOL
. 'ExpiresByType image/svg+xml "access plus 1 month"' . PHP_EOL
. '</IfModule>'
, $htaccess_data );
}
else
{
$htaccess_data = str_replace( '{HTACCESS_CACHE}',
'<IfModule mod_headers.c>' . PHP_EOL
. 'Header set Cache-Control "no-cache, no-store, must-revalidate"' . PHP_EOL
. 'Header set Pragma "no-cache"' . PHP_EOL
. 'Header set Expires 0' . PHP_EOL
. '</IfModule>',
$htaccess_data );
}
$htaccess_data .= PHP_EOL;
$htaccess_data .= 'RewriteCond %{REQUEST_FILENAME} !-f' . PHP_EOL;
$htaccess_data .= 'RewriteCond %{REQUEST_FILENAME} !-d' . PHP_EOL;
$htaccess_data .= 'RewriteRule ^ index.php [L]';
// Niektore hostingi blokuja zmiane wersji PHP przez .htaccess.
// Automatycznie komentujemy niedozwolone dyrektywy, aby generowany plik byl kompatybilny.
$htaccess_data = preg_replace( '/^(\\s*)(AddHandler|SetHandler|ForceType)\\b/im', '$1# $2', $htaccess_data );
$fp = fopen( $dir . '.htaccess', 'w' );

View File

@@ -423,7 +423,8 @@ class App
new \Domain\Order\OrderRepository( $mdb ),
$productRepo,
new \Domain\Settings\SettingsRepository( $mdb ),
new \Domain\Transport\TransportRepository( $mdb )
new \Domain\Transport\TransportRepository( $mdb ),
new \Domain\CronJob\CronJobRepository( $mdb )
),
$productRepo
);

View File

@@ -92,6 +92,7 @@ class ProductArchiveController
. $skuEanHtml;
$rows[] = [
'_checkbox' => '<input type="checkbox" class="js-bulk-check" value="' . $id . '" aria-label="Zaznacz produkt">',
'lp' => $lp++ . '.',
'product' => $productCell,
'price_brutto' => $priceBrutto !== '' ? $priceBrutto : '-',
@@ -123,6 +124,7 @@ class ProductArchiveController
$viewModel = new \admin\ViewModels\Common\PaginatedTableViewModel(
[
['key' => '_checkbox', 'label' => '', 'class' => 'text-center table-col-bulk-check', 'sortable' => false, 'raw' => true],
['key' => 'lp', 'label' => 'Lp.', 'class' => 'text-center', 'sortable' => false],
['key' => 'product', 'sort_key' => 'name', 'label' => 'Nazwa', 'sortable' => true, 'raw' => true],
['key' => 'price_brutto', 'sort_key' => 'price_brutto', 'label' => 'Cena', 'class' => 'text-center', 'sortable' => true],
@@ -190,4 +192,40 @@ class ProductArchiveController
header( 'Location: /admin/product_archive/list/' );
exit;
}
public function bulk_delete_permanent(): void
{
header( 'Content-Type: application/json; charset=utf-8' );
$rawIds = isset( $_POST['ids'] ) && is_array( $_POST['ids'] ) ? $_POST['ids'] : [];
$ids = [];
foreach ( $rawIds as $raw ) {
$id = (int) $raw;
if ( $id > 0 ) {
$ids[] = $id;
}
}
if ( empty( $ids ) ) {
echo json_encode( ['success' => false, 'message' => 'Nie wybrano żadnych produktów.'] );
exit;
}
$deleted = 0;
$errors = [];
foreach ( $ids as $id ) {
if ( $this->productRepository->delete( $id ) ) {
$deleted++;
} else {
$errors[] = $id;
}
}
echo json_encode( [
'success' => empty( $errors ),
'deleted' => $deleted,
'errors' => $errors,
] );
exit;
}
}

View File

@@ -87,7 +87,8 @@ class ApiRouter
$settingsRepo = new \Domain\Settings\SettingsRepository($db);
$productRepo = new \Domain\Product\ProductRepository($db);
$transportRepo = new \Domain\Transport\TransportRepository($db);
$service = new \Domain\Order\OrderAdminService($orderRepo, $productRepo, $settingsRepo, $transportRepo);
$cronJobRepo = new \Domain\CronJob\CronJobRepository($db);
$service = new \Domain\Order\OrderAdminService($orderRepo, $productRepo, $settingsRepo, $transportRepo, $cronJobRepo);
return new Controllers\OrdersApiController($service, $orderRepo);
},
'products' => function () use ($db) {
@@ -103,6 +104,9 @@ class ApiRouter
$producerRepo = new \Domain\Producer\ProducerRepository($db);
return new Controllers\DictionariesApiController($statusRepo, $transportRepo, $paymentRepo, $attrRepo, $producerRepo);
},
'categories' => function () use ($db) {
return new Controllers\CategoriesApiController();
},
];
}

View File

@@ -0,0 +1,104 @@
<?php
namespace api\Controllers;
use api\ApiRouter;
class CategoriesApiController
{
public function list(): void
{
if (!ApiRouter::requireMethod('GET')) {
return;
}
$db = $GLOBALS['mdb'] ?? null;
if (!$db) {
ApiRouter::sendError('INTERNAL_ERROR', 'Database not available', 500);
return;
}
// Default shop language
$defaultLang = $db->get('pp_langs', 'id', ['start' => 1]);
if (!$defaultLang) {
$defaultLang = 'pl';
}
$defaultLang = (string)$defaultLang;
// All active categories, ordered by display order
$rows = $db->select(
'pp_shop_categories',
['id', 'parent_id'],
[
'status' => 1,
'ORDER' => ['o' => 'ASC'],
]
);
if (!is_array($rows) || empty($rows)) {
ApiRouter::sendSuccess(['categories' => []]);
return;
}
$categoryIds = array_values(array_filter(
array_map(fn($row) => (int)($row['id'] ?? 0), $rows),
fn($id) => $id > 0
));
// Bulk fetch titles for default language
$titlesByCategory = [];
$titleRows = $db->select('pp_shop_categories_langs', ['category_id', 'title'], [
'AND' => [
'category_id' => $categoryIds,
'lang_id' => $defaultLang,
'title[!]' => '',
],
]);
if (is_array($titleRows)) {
foreach ($titleRows as $tr) {
$tid = (int)($tr['category_id'] ?? 0);
if ($tid > 0 && !isset($titlesByCategory[$tid])) {
$titlesByCategory[$tid] = (string)($tr['title'] ?? '');
}
}
}
// Bulk fetch fallback titles for categories without a title in default language
$missingIds = array_values(array_filter($categoryIds, fn($id) => !isset($titlesByCategory[$id])));
if (!empty($missingIds)) {
$fallbackRows = $db->select('pp_shop_categories_langs', ['category_id', 'title'], [
'AND' => [
'category_id' => $missingIds,
'title[!]' => '',
],
]);
if (is_array($fallbackRows)) {
foreach ($fallbackRows as $fr) {
$fid = (int)($fr['category_id'] ?? 0);
if ($fid > 0 && !isset($titlesByCategory[$fid])) {
$titlesByCategory[$fid] = (string)($fr['title'] ?? '');
}
}
}
}
// Build flat category list
$categories = [];
foreach ($rows as $row) {
$categoryId = (int)($row['id'] ?? 0);
if ($categoryId <= 0) {
continue;
}
$parentId = $row['parent_id'] !== null ? (int)$row['parent_id'] : null;
$title = $titlesByCategory[$categoryId] ?? ('Kategoria #' . $categoryId);
$categories[] = [
'id' => $categoryId,
'parent_id' => $parentId,
'title' => $title,
];
}
ApiRouter::sendSuccess(['categories' => $categories]);
}
}

View File

@@ -177,9 +177,10 @@ class App
'ShopOrder' => function() {
global $mdb;
$orderRepo = new \Domain\Order\OrderRepository( $mdb );
$cronJobRepo = new \Domain\CronJob\CronJobRepository( $mdb );
return new \front\Controllers\ShopOrderController(
$orderRepo,
new \Domain\Order\OrderAdminService( $orderRepo )
new \Domain\Order\OrderAdminService( $orderRepo, null, null, null, $cronJobRepo )
);
},
'ShopProducer' => function() {

View File

@@ -215,7 +215,10 @@ $sqlQueries = @()
$migrationFile = "migrations/$versionNumber.sql"
if (Test-Path $migrationFile) {
$sqlQueries = @(Get-Content $migrationFile | Where-Object { $_.Trim() -ne '' } | ForEach-Object { $_.ToString() })
# Read entire file, strip comment lines, split by semicolons to get complete SQL statements
$rawLines = Get-Content $migrationFile | Where-Object { $_.Trim() -ne '' -and $_.Trim() -notmatch '^\s*--' }
$rawSql = ($rawLines -join "`n").Trim()
$sqlQueries = @($rawSql -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' } | ForEach-Object { $_.ToString() })
Write-Step "Znaleziono migracje SQL: $migrationFile ($($sqlQueries.Count) zapytan)"
} else {
Write-Step "Brak migracji SQL ($migrationFile nie istnieje)"
@@ -368,9 +371,9 @@ if (Test-Path $versionsFile) {
Write-Ok "Zaktualizowano versions.php: `$current_ver = $versionInt"
}
# --- 14. Aktualizacja changelog.php ---
# --- 14. Aktualizacja changelog-data.html ---
$changelogFile = "updates/changelog.php"
$changelogFile = "updates/changelog-data.html"
if (Test-Path $changelogFile) {
$dateStr = Get-Date -Format "dd.MM.yyyy"
$newEntry = "<b>ver. $versionNumber - $dateStr</b><br />`n$ChangelogEntry`n<hr>`n"
@@ -378,7 +381,7 @@ if (Test-Path $changelogFile) {
$changelogContent = Get-Content $changelogFile -Raw
$changelogContent = $newEntry + $changelogContent
[System.IO.File]::WriteAllText($changelogFile, $changelogContent, $Utf8NoBom)
Write-Ok "Zaktualizowano changelog.php"
Write-Ok "Zaktualizowano changelog-data.html"
}
# --- 15. Cleanup ---

View File

@@ -17,4 +17,6 @@ $config['debug']['apilo'] = true;
$config['trustmate']['enabled'] = true;
$config['trustmate']['uid'] = '34eb36ba-c715-4cdc-8707-22376c9f14c7';
$config['cron_key'] = 'Gi7FzWtkry19hZ1BqT1LKEWfwokQpigh';
?>

467
cron.php
View File

@@ -50,19 +50,26 @@ $mdb = new medoo( [
'charset' => 'utf8'
] );
$settings = ( new \Domain\Settings\SettingsRepository( $mdb ) )->allSettings();
$integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb );
$apilo_settings = $integrationsRepository -> getSettings( 'apilo' );
// =========================================================================
// Auth: cron endpoint protection
// =========================================================================
// Keepalive tokenu Apilo: odswiezaj token przed wygasnieciem, zeby integracja byla stale aktywna.
if ( (int)($apilo_settings['enabled'] ?? 0) === 1 ) {
$integrationsRepository -> apiloKeepalive( 300 );
$apilo_settings = $integrationsRepository -> getSettings( 'apilo' );
$orderRepo = new \Domain\Order\OrderRepository( $mdb );
$orderAdminService = new \Domain\Order\OrderAdminService( $orderRepo );
$orderAdminService->processApiloSyncQueue( 10 );
if ( php_sapi_name() !== 'cli' )
{
$cron_key = isset( $config['cron_key'] ) ? $config['cron_key'] : '';
$provided_key = isset( $_GET['key'] ) ? $_GET['key'] : '';
if ( $cron_key === '' || $provided_key !== $cron_key )
{
http_response_code( 403 );
exit( 'Forbidden' );
}
}
// =========================================================================
// Helper functions (used by handlers)
// =========================================================================
function parsePaczkomatAddress($input)
{
$pattern = '/^([\w-]+)\s+\|\s+([^,]+),\s+(\d{2}-\d{3})\s+(.+)$/';
@@ -118,93 +125,90 @@ function getImageUrlById($id) {
return isset($data['img']) ? $data['img'] : null;
}
// pobieranie informacji o produkcie z apilo.com
if ( $apilo_settings['enabled'] and $apilo_settings['sync_products'] and $apilo_settings['access-token'] )
// =========================================================================
// Shared dependencies
// =========================================================================
$settings = ( new \Domain\Settings\SettingsRepository( $mdb ) )->allSettings();
$integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb );
$orderRepo = new \Domain\Order\OrderRepository( $mdb );
$cronRepo = new \Domain\CronJob\CronJobRepository( $mdb );
$orderAdminService = new \Domain\Order\OrderAdminService( $orderRepo, null, null, null, $cronRepo );
$processor = new \Domain\CronJob\CronJobProcessor( $cronRepo );
// =========================================================================
// One-time migration: JSON queue → DB
// =========================================================================
$json_queue_path = __DIR__ . '/temp/apilo-sync-queue.json';
if ( file_exists( $json_queue_path ) )
{
if ( $result = $mdb -> query( 'SELECT id, apilo_product_id, apilo_get_data_date, apilo_product_name FROM pp_shop_products WHERE apilo_product_id IS NOT NULL AND apilo_product_id != 0 AND ( apilo_get_data_date IS NULL OR apilo_get_data_date <= \'' . date( 'Y-m-d H:i:s', strtotime( '-10 minutes', time() ) ) . '\' ) ORDER BY apilo_get_data_date ASC LIMIT 1' ) -> fetch( \PDO::FETCH_ASSOC ) )
$json_content = file_get_contents( $json_queue_path );
$json_queue = $json_content ? json_decode( $json_content, true ) : [];
if ( is_array( $json_queue ) )
{
$access_token = $integrationsRepository -> apiloGetAccessToken();
$url = 'https://projectpro.apilo.com/rest/api/warehouse/product/' . $result['apilo_product_id'] . '/';
$curl = curl_init( $url );
curl_setopt( $curl, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $curl, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $access_token,
"Accept: application/json"
] );
$response = curl_exec( $curl );
$responseData = json_decode( $response, true );
// aktualizowanie stanu magazynowego
$mdb -> update( 'pp_shop_products', [ 'quantity' => $responseData['quantity'] ], [ 'apilo_product_id' => $result['apilo_product_id'] ] );
// aktualizowanie ceny
$mdb -> update( 'pp_shop_products', [ 'price_netto' => \Shared\Helpers\Helpers::normalize_decimal( $responseData['priceWithoutTax'], 2 ), 'price_brutto' => \Shared\Helpers\Helpers::normalize_decimal( $responseData['priceWithTax'], 2 ) ], [ 'apilo_product_id' => $result['apilo_product_id'] ] );
$mdb -> update( 'pp_shop_products', [ 'apilo_get_data_date' => date( 'Y-m-d H:i:s' ) ], [ 'apilo_product_id' => $result['apilo_product_id'] ] );
// Czyszczenie cache produktu
\Shared\Helpers\Helpers::clear_product_cache( (int)$result['id'] );
echo '<p>Zaktualizowałem dane produktu (APILO) <b>' . $result['apilo_product_name'] . ' #' . $result['id'] . '</b></p>';
}
}
// synchronizacja cen apilo.com
if ( $apilo_settings['enabled'] and $apilo_settings['access-token'] and ( !$apilo_settings['pricelist_update_date'] or $apilo_settings['pricelist_update_date'] <= date( 'Y-m-d H:i:s', strtotime( '-1 hour', time() ) ) ) )
{
$access_token = $integrationsRepository -> apiloGetAccessToken();
$url = 'https://projectpro.apilo.com/rest/api/warehouse/price-calculated/?price=' . $apilo_settings['pricelist_id'];
$curl = curl_init( $url );
curl_setopt( $curl, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $curl, CURLOPT_CUSTOMREQUEST, "GET" );
curl_setopt( $curl, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $access_token,
"Accept: application/json",
"Content-Type: application/json"
] );
$response = curl_exec( $curl );
$responseData = json_decode( $response, true );
if ( $responseData['list'] )
{
foreach ( $responseData['list'] as $product_price )
foreach ( $json_queue as $task )
{
//aktualizowanie ceny
if ( $product_price['customPriceWithTax'] )
$order_id = (int)($task['order_id'] ?? 0);
if ( $order_id <= 0 ) continue;
if ( !empty($task['payment']) )
{
$price_brutto = $product_price['customPriceWithTax'];
$vat = $vat = $mdb -> get( 'pp_shop_products', 'vat', [ 'apilo_product_id' => $result['apilo_product_id'] ] );
$price_netto = $price_brutto / ( ( 100 + $vat ) / 100 );
$mdb -> update( 'pp_shop_products', [ 'price_netto' => \Shared\Helpers\Helpers::normalize_decimal( $price_netto, 2 ), 'price_brutto' => \Shared\Helpers\Helpers::normalize_decimal( $price_brutto, 2 ) ], [ 'apilo_product_id' => $product_price['product'] ] );
$product_id = $mdb -> get( 'pp_shop_products', 'id', [ 'apilo_product_id' => $product_price['product'] ] );
( new \Domain\Product\ProductRepository( $mdb ) )->updateCombinationPricesFromBase( (int)$product_id, $price_brutto, $vat, null );
// Czyszczenie cache produktu
\Shared\Helpers\Helpers::clear_product_cache( (int)$product_id );
$cronRepo->enqueue(
\Domain\CronJob\CronJobType::APILO_SYNC_PAYMENT,
['order_id' => $order_id],
\Domain\CronJob\CronJobType::PRIORITY_HIGH,
50
);
}
if ( isset($task['status']) && $task['status'] !== null && $task['status'] !== '' )
{
$cronRepo->enqueue(
\Domain\CronJob\CronJobType::APILO_SYNC_STATUS,
['order_id' => $order_id, 'status' => (int)$task['status']],
\Domain\CronJob\CronJobType::PRIORITY_HIGH,
50
);
}
}
}
$integrationsRepository -> saveSetting( 'apilo', 'pricelist_update_date', date( 'Y-m-d H:i:s' ) );
echo '<p>Zaktualizowałem ceny produktów (APILO)</p>';
unlink( $json_queue_path );
echo '<p>Migracja kolejki JSON → DB zakończona</p>';
}
// wysyłanie zamówień do apilo
if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_settings['access-token'] and $apilo_settings['sync_orders_date_start'] <= date( 'Y-m-d H:i:s' ) )
{
$orders = $mdb -> select( 'pp_shop_orders', '*', [ 'AND' => [ 'apilo_order_id' => null, 'date_order[>=]' => $apilo_settings['sync_orders_date_start'] ], 'ORDER' => [ 'date_order' => 'ASC' ], 'LIMIT' => 1 ] );
// =========================================================================
// Handler registration
// =========================================================================
// 1. Apilo token keepalive (priorytet: krytyczny)
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_TOKEN_KEEPALIVE, function($payload) use ($integrationsRepository) {
$apilo_settings = $integrationsRepository->getSettings('apilo');
if ( !(int)($apilo_settings['enabled'] ?? 0) ) return true; // skip if disabled
$integrationsRepository->apiloKeepalive( 300 );
echo '<p>Apilo token keepalive</p>';
return true;
});
// 2. Apilo send order (priorytet: wysoki)
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_SEND_ORDER, function($payload) use ($mdb, $integrationsRepository, $orderAdminService, $config) {
$apilo_settings = $integrationsRepository->getSettings('apilo');
if ( !$apilo_settings['enabled'] || !$apilo_settings['sync_orders'] || !$apilo_settings['access-token'] || $apilo_settings['sync_orders_date_start'] > date('Y-m-d H:i:s') ) return true;
$orders = $mdb->select( 'pp_shop_orders', '*', [ 'AND' => [ 'apilo_order_id' => null, 'date_order[>=]' => $apilo_settings['sync_orders_date_start'] ], 'ORDER' => [ 'date_order' => 'ASC' ], 'LIMIT' => 1 ] );
if ( empty($orders) ) return true;
foreach ( $orders as $order )
{
$products = $mdb -> select( 'pp_shop_order_products', '*', [ 'order_id' => $order['id'] ] );
$products = $mdb->select( 'pp_shop_order_products', '*', [ 'order_id' => $order['id'] ] );
$productRepo = new \Domain\Product\ProductRepository( $mdb );
$products_array = [];
$order_message = '';
foreach ( $products as $product )
{
$productRepo = new \Domain\Product\ProductRepository( $mdb );
$sku = $productRepo->getSkuWithFallback( (int)$product['product_id'], true );
$products_array[] = [
@@ -237,11 +241,9 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
$order_message .= '<hr>';
}
//TODO: ostatnio był problem kiedy wiadomość miała mniej 1024 znaki ale zawierała przeniesienie tekstu '<br>' i do tego jeszcze miała emoji. Wtedy APILO tego nie przepuszczał.
if ( strlen( $order_message ) > 850 )
$order_message = '<p><strong>Wiadomość do zamówienia była zbyt długa. Sprawdź szczegóły w panelu sklepu</strong></p>';
// add transport as product
$products_array[] = [
'idExternal' => '',
'ean' => null,
@@ -256,7 +258,6 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
'media' => null
];
// Walidacja: sprawdź czy zamówienie ma produkty z cenami > 0
$has_priced_products = false;
foreach ( $products_array as $pa )
{
@@ -270,15 +271,13 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
{
\Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Pominięto zamówienie - wszystkie produkty mają cenę 0.00', [ 'products' => $products_array ] );
\Shared\Helpers\Helpers::send_email( 'biuro@project-pro.pl', 'Apilo: zamówienie #' . $order['id'] . ' ma zerowe ceny produktów', 'Zamówienie #' . $order['id'] . ' nie zostało wysłane do Apilo, ponieważ wszystkie produkty mają cenę 0.00 PLN. Sprawdź zamówienie w panelu sklepu.' );
$mdb -> update( 'pp_shop_orders', [ 'apilo_order_id' => -2 ], [ 'id' => $order['id'] ] );
$mdb->update( 'pp_shop_orders', [ 'apilo_order_id' => -2 ], [ 'id' => $order['id'] ] );
echo '<p>Pominięto zamówienie #' . $order['id'] . ' - zerowe ceny produktów</p>';
continue;
}
$access_token = $integrationsRepository -> apiloGetAccessToken();
$access_token = $integrationsRepository->apiloGetAccessToken();
$order_date = new DateTime( $order['date_order'] );
$paczkomatData = parsePaczkomatAddress( $order['inpost_paczkomat'] );
$orlenPointData = parseOrlenAddress( $order['orlen_point'] );
@@ -326,7 +325,7 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
'originalCurrency' => 'PLN',
'originalAmountTotalWithTax' => str_replace( ',', '.', $order['summary'] ),
'orderItems' => $products_array,
'orderedAt' => $order_date -> format('Y-m-d\TH:i:s\Z'),
'orderedAt' => $order_date->format('Y-m-d\TH:i:s\Z'),
'addressCustomer' => [
'name' => $order['client_name'] . ' ' . $order['client_surname'],
'phone' => $order['client_phone'],
@@ -361,7 +360,6 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
$postData['addressInvoice']['companyTaxNumber'] = $order['firm_nip'];
}
// jeżeli paczkomat
if ( $order['inpost_paczkomat'] )
{
$postData['addressDelivery']['parcelName'] = $order['inpost_paczkomat'] ? 'Paczkomat: ' . $order['inpost_paczkomat'] : null;
@@ -381,7 +379,6 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
}
}
// jeżeli orlen paczka
if ( $order['orlen_point'] )
{
$postData['addressDelivery']['parcelName'] = $order['orlen_point'] ? 'Automat ORLEN ' . $order['orlen_point'] : null;
@@ -399,16 +396,14 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
$postData['addressDelivery']['zipCode'] = $postalCode;
$postData['addressDelivery']['city'] = $city;
}
}
if ( $order['paid'] )
{
$payment_date = new DateTime( $order['date_order'] );
$postData['orderPayments'][] = [
'amount' => str_replace( ',', '.', $order['summary'] ),
'paymentDate' => $payment_date -> format('Y-m-d\TH:i:s\Z'),
'paymentDate' => $payment_date->format('Y-m-d\TH:i:s\Z'),
'type' => ( new \Domain\PaymentMethod\PaymentMethodRepository( $mdb ) )->getApiloPaymentTypeId( (int)$order['payment_method_id'] )
];
}
@@ -435,30 +430,29 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
$response = json_decode( $response, true );
if ( $config['debug']['apilo'] )
if ( isset($config['debug']['apilo']) && $config['debug']['apilo'] )
{
file_put_contents( $_SERVER['DOCUMENT_ROOT'] . '/logs/apilo.txt', date( 'Y-m-d H:i:s' ) . " --- SEND ORDER TO APILO\n\n", FILE_APPEND );
file_put_contents( $_SERVER['DOCUMENT_ROOT'] . '/logs/apilo.txt', print_r( $postData, true ) . "\n\n", FILE_APPEND );
file_put_contents( $_SERVER['DOCUMENT_ROOT'] . '/logs/apilo.txt', print_r( $response, true ) . "\n\n", FILE_APPEND );
}
if ( $response['message'] == 'Order already exists' )
if ( isset($response['message']) && $response['message'] == 'Order already exists' )
{
$apilo_order_id = str_replace( 'Order id: ', '', $response['description'] );
$mdb -> update( 'pp_shop_orders', [ 'apilo_order_id' => $apilo_order_id ], [ 'id' => $order['id'] ] );
$mdb->update( 'pp_shop_orders', [ 'apilo_order_id' => $apilo_order_id ], [ 'id' => $order['id'] ] );
\Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Zamówienie już istnieje w Apilo (apilo_order_id: ' . $apilo_order_id . ')', [ 'http_code' => $http_code_send, 'response' => $response ] );
echo '<p>Zaktualizowałem id zamówienia na podstawie zamówienia apilo.com</p>';
}
elseif ( $response['message'] == 'Validation error' )
elseif ( isset($response['message']) && $response['message'] == 'Validation error' )
{
// sprawdzanie czy błąd dotyczy duplikatu idExternal
$is_duplicate_idexternal = false;
if ( isset( $response['errors'] ) and is_array( $response['errors'] ) )
if ( isset( $response['errors'] ) && is_array( $response['errors'] ) )
{
foreach ( $response['errors'] as $error )
{
if ( isset( $error['field'] ) and $error['field'] == 'idExternal' and
( strpos( $error['message'], 'już wykorzystywana' ) !== false or
if ( isset( $error['field'] ) && $error['field'] == 'idExternal' &&
( strpos( $error['message'], 'już wykorzystywana' ) !== false ||
strpos( $error['message'], 'already' ) !== false ) )
{
$is_duplicate_idexternal = true;
@@ -469,7 +463,6 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
if ( $is_duplicate_idexternal )
{
// próba pobrania zamówienia z Apilo na podstawie idExternal
$ch_get = curl_init();
curl_setopt( $ch_get, CURLOPT_URL, "https://projectpro.apilo.com/rest/api/orders/?idExternal=" . $order['id'] );
curl_setopt( $ch_get, CURLOPT_RETURNTRANSFER, true );
@@ -482,22 +475,16 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
$get_response_data = json_decode( $get_response, true );
if ( isset( $get_response_data['list'] ) and count( $get_response_data['list'] ) > 0 )
if ( isset( $get_response_data['list'] ) && count( $get_response_data['list'] ) > 0 )
{
$apilo_order_id = $get_response_data['list'][0]['id'];
$mdb -> update( 'pp_shop_orders', [ 'apilo_order_id' => $apilo_order_id ], [ 'id' => $order['id'] ] );
$mdb->update( 'pp_shop_orders', [ 'apilo_order_id' => $apilo_order_id ], [ 'id' => $order['id'] ] );
\Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Duplikat idExternal - pobrano apilo_order_id: ' . $apilo_order_id, [ 'http_code' => $http_code_send, 'response' => $response, 'get_response' => $get_response_data ] );
echo '<p>Zamówienie już istnieje w Apilo. Zaktualizowano ID zamówienia: ' . $apilo_order_id . '</p>';
}
else
{
echo '<pre>';
echo print_r( $response, true );
echo print_r( $postData, true );
echo '</pre>';
\Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Błąd: duplikat idExternal, ale nie znaleziono zamówienia w Apilo', [ 'http_code' => $http_code_send, 'response' => $response, 'get_response' => $get_response_data ] );
$email_data = print_r( $response, true );
$email_data .= print_r( $postData, true );
\Shared\Helpers\Helpers::send_email( 'biuro@project-pro.pl', 'Błąd wysyłania zamówienia do apilo.com - nie znaleziono zamówienia', $email_data );
@@ -505,13 +492,7 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
}
else
{
echo '<pre>';
echo print_r( $response, true );
echo print_r( $postData, true );
echo '</pre>';
\Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Błąd walidacji wysyłania zamówienia do Apilo', [ 'http_code' => $http_code_send, 'response' => $response ] );
$email_data = print_r( $response, true );
$email_data .= print_r( $postData, true );
\Shared\Helpers\Helpers::send_email( 'biuro@project-pro.pl', 'Błąd wysyłania zamówienia do apilo.com', $email_data );
@@ -519,39 +500,146 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
}
elseif ( $http_code_send >= 400 || !isset( $response['id'] ) )
{
// Błąd serwera lub brak ID w odpowiedzi — logujemy i pomijamy, NIE ustawiamy apilo_order_id
// żeby zamówienie nie wpadło w nieskończoną pętlę, ustawiamy apilo_order_id na -1 (błąd)
$mdb -> update( 'pp_shop_orders', [ 'apilo_order_id' => -1 ], [ 'id' => $order['id'] ] );
$mdb->update( 'pp_shop_orders', [ 'apilo_order_id' => -1 ], [ 'id' => $order['id'] ] );
\Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Błąd wysyłania zamówienia do Apilo (HTTP ' . $http_code_send . ')', [ 'http_code' => $http_code_send, 'response' => $response ] );
$email_data = 'HTTP Code: ' . $http_code_send . "\n\n";
$email_data .= print_r( $response, true );
$email_data .= print_r( $postData, true );
\Shared\Helpers\Helpers::send_email( 'biuro@project-pro.pl', 'Błąd wysyłania zamówienia #' . $order['id'] . ' do apilo.com (HTTP ' . $http_code_send . ')', $email_data );
echo '<p>Błąd wysyłania zamówienia do apilo.com: ID: ' . $order['id'] . ' (HTTP ' . $http_code_send . ')</p>';
}
else
{
$mdb -> update( 'pp_shop_orders', [ 'apilo_order_id' => $response['id'] ], [ 'id' => $order['id'] ] );
$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 ] );
echo '<p>Wysłałem zamówienie do apilo.com: ID: ' . $order['id'] . ' - ' . $response['id'] . '</p>';
}
}
// Po wysłaniu zamówień: przetwórz kolejkę sync (płatności/statusy oczekujące na apilo_order_id)
$orderAdminService->processApiloSyncQueue( 10 );
}
return true;
});
// 3. Apilo sync payment (event-driven — enqueued by OrderAdminService)
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_SYNC_PAYMENT, function($payload) use ($mdb, $orderRepo, $orderAdminService) {
$order_id = (int)($payload['order_id'] ?? 0);
if ( $order_id <= 0 ) return true;
$order = $orderRepo->findRawById( $order_id );
if ( !$order ) return true;
if ( empty($order['apilo_order_id']) ) return false; // retry — awaiting apilo_order_id
if ( (int)$order['paid'] !== 1 ) return true; // not paid — nothing to sync
return $orderAdminService->syncApiloPayment( $order );
});
// 4. Apilo sync status (event-driven — enqueued by OrderAdminService)
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_SYNC_STATUS, function($payload) use ($mdb, $orderRepo, $orderAdminService) {
$order_id = (int)($payload['order_id'] ?? 0);
$status = isset($payload['status']) ? (int)$payload['status'] : null;
if ( $order_id <= 0 || $status === null ) return true;
$order = $orderRepo->findRawById( $order_id );
if ( !$order ) return true;
if ( empty($order['apilo_order_id']) ) return false; // retry — awaiting apilo_order_id
return $orderAdminService->syncApiloStatus( $order, $status );
});
// 5. Apilo product sync
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_PRODUCT_SYNC, function($payload) use ($mdb, $integrationsRepository) {
$apilo_settings = $integrationsRepository->getSettings('apilo');
if ( !$apilo_settings['enabled'] || !$apilo_settings['sync_products'] || !$apilo_settings['access-token'] ) return true;
$stmt = $mdb->query( 'SELECT id, apilo_product_id, apilo_get_data_date, apilo_product_name FROM pp_shop_products WHERE apilo_product_id IS NOT NULL AND apilo_product_id != 0 AND ( apilo_get_data_date IS NULL OR apilo_get_data_date <= \'' . date( 'Y-m-d H:i:s', strtotime( '-10 minutes', time() ) ) . '\' ) ORDER BY apilo_get_data_date ASC LIMIT 1' );
$result = $stmt ? $stmt->fetch( \PDO::FETCH_ASSOC ) : null;
if ( !$result ) return true;
$access_token = $integrationsRepository->apiloGetAccessToken();
$url = 'https://projectpro.apilo.com/rest/api/warehouse/product/' . $result['apilo_product_id'] . '/';
$curl = curl_init( $url );
curl_setopt( $curl, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $curl, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $access_token,
"Accept: application/json"
] );
$response = curl_exec( $curl );
if ( $response === false ) return false;
$responseData = json_decode( $response, true );
if ( !is_array( $responseData ) || !isset( $responseData['quantity'] ) ) return false;
$mdb->update( 'pp_shop_products', [ 'quantity' => $responseData['quantity'] ], [ 'apilo_product_id' => $result['apilo_product_id'] ] );
$mdb->update( 'pp_shop_products', [ 'price_netto' => \Shared\Helpers\Helpers::normalize_decimal( $responseData['priceWithoutTax'], 2 ), 'price_brutto' => \Shared\Helpers\Helpers::normalize_decimal( $responseData['priceWithTax'], 2 ) ], [ 'apilo_product_id' => $result['apilo_product_id'] ] );
$mdb->update( 'pp_shop_products', [ 'apilo_get_data_date' => date( 'Y-m-d H:i:s' ) ], [ 'apilo_product_id' => $result['apilo_product_id'] ] );
\Shared\Helpers\Helpers::clear_product_cache( (int)$result['id'] );
echo '<p>Zaktualizowałem dane produktu (APILO) <b>' . $result['apilo_product_name'] . ' #' . $result['id'] . '</b></p>';
return true;
});
// 6. Apilo pricelist sync
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_PRICELIST_SYNC, function($payload) use ($mdb, $integrationsRepository) {
$apilo_settings = $integrationsRepository->getSettings('apilo');
if ( !$apilo_settings['enabled'] || !$apilo_settings['access-token'] ) return true;
$access_token = $integrationsRepository->apiloGetAccessToken();
$url = 'https://projectpro.apilo.com/rest/api/warehouse/price-calculated/?price=' . $apilo_settings['pricelist_id'];
$curl = curl_init( $url );
curl_setopt( $curl, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $curl, CURLOPT_CUSTOMREQUEST, "GET" );
curl_setopt( $curl, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $access_token,
"Accept: application/json",
"Content-Type: application/json"
] );
$response = curl_exec( $curl );
if ( $response === false ) return false;
$responseData = json_decode( $response, true );
if ( !is_array( $responseData ) ) return false;
if ( isset($responseData['list']) && $responseData['list'] )
{
foreach ( $responseData['list'] as $product_price )
{
if ( $product_price['customPriceWithTax'] )
{
$price_brutto = $product_price['customPriceWithTax'];
$vat = $mdb->get( 'pp_shop_products', 'vat', [ 'apilo_product_id' => $product_price['product'] ] );
$price_netto = $price_brutto / ( ( 100 + $vat ) / 100 );
$mdb->update( 'pp_shop_products', [ 'price_netto' => \Shared\Helpers\Helpers::normalize_decimal( $price_netto, 2 ), 'price_brutto' => \Shared\Helpers\Helpers::normalize_decimal( $price_brutto, 2 ) ], [ 'apilo_product_id' => $product_price['product'] ] );
$product_id = $mdb->get( 'pp_shop_products', 'id', [ 'apilo_product_id' => $product_price['product'] ] );
( new \Domain\Product\ProductRepository( $mdb ) )->updateCombinationPricesFromBase( (int)$product_id, $price_brutto, $vat, null );
\Shared\Helpers\Helpers::clear_product_cache( (int)$product_id );
}
}
}
$integrationsRepository->saveSetting( 'apilo', 'pricelist_update_date', date( 'Y-m-d H:i:s' ) );
echo '<p>Zaktualizowałem ceny produktów (APILO)</p>';
return true;
});
// 7. Apilo status poll
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_STATUS_POLL, function($payload) use ($mdb, $integrationsRepository, $orderRepo, $orderAdminService) {
$apilo_settings = $integrationsRepository->getSettings('apilo');
if ( !$apilo_settings['enabled'] || !$apilo_settings['sync_orders'] || !$apilo_settings['access-token'] ) return true;
$stmt = $mdb->query( 'SELECT id, apilo_order_id, apilo_order_status_date, number FROM pp_shop_orders WHERE apilo_order_id IS NOT NULL AND ( status != 6 AND status != 8 AND status != 9 ) AND ( apilo_order_status_date IS NULL OR apilo_order_status_date <= \'' . date( 'Y-m-d H:i:s', strtotime( '-10 minutes', time() ) ) . '\' ) ORDER BY apilo_order_status_date ASC LIMIT 5' );
$orders = $stmt ? $stmt->fetchAll( \PDO::FETCH_ASSOC ) : [];
// sprawdzanie statusów zamówień w apilo.com jeżeli zamówienie nie jest zrealizowane, anulowane lub nieodebrane
if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_settings['access-token'] and $apilo_settings['sync_orders_date_start'] <= date( 'Y-m-d H:i:s' ) )
{
$orders = $mdb -> query( 'SELECT id, apilo_order_id, apilo_order_status_date, number FROM pp_shop_orders WHERE apilo_order_id IS NOT NULL AND ( status != 6 AND status != 8 AND status != 9 ) AND ( apilo_order_status_date IS NULL OR apilo_order_status_date <= \'' . date( 'Y-m-d H:i:s', strtotime( '-10 minutes', time() ) ) . '\' ) ORDER BY apilo_order_status_date ASC LIMIT 5' ) -> fetchAll( \PDO::FETCH_ASSOC );
foreach ( $orders as $order )
{
if ( $order['apilo_order_id'] )
{
$access_token = $integrationsRepository -> apiloGetAccessToken();
$access_token = $integrationsRepository->apiloGetAccessToken();
$url = 'https://projectpro.apilo.com/rest/api/orders/' . $order['apilo_order_id'] . '/';
$ch = curl_init( $url );
@@ -565,70 +653,103 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
$http_code_poll = (int)curl_getinfo( $ch, CURLINFO_HTTP_CODE );
$responseData = json_decode( $response, true );
if ( $responseData['id'] and $responseData['status'] )
if ( isset($responseData['id']) && $responseData['id'] && isset($responseData['status']) && $responseData['status'] )
{
$shop_status_id = ( new \Domain\ShopStatus\ShopStatusRepository( $mdb ) )->getByIntegrationStatusId( 'apilo', (int)$responseData['status'] );
if ( $shop_status_id )
$orderAdminService->changeStatus( (int)$order['id'], $shop_status_id, false );
\Domain\Integrations\ApiloLogger::log( $mdb, 'status_poll', (int)$order['id'], 'Status pobrany z Apilo (apilo_status: ' . $responseData['status'] . ', shop_status: ' . ($shop_status_id ?: 'brak mapowania') . ')', [ 'apilo_order_id' => $order['apilo_order_id'], 'http_code' => $http_code_poll, 'response' => $responseData ] );
\Domain\Integrations\ApiloLogger::log( $mdb, 'status_poll', (int)$order['id'], 'Status pobrany z Apilo (apilo_status: ' . $responseData['status'] . ', shop_status: ' . ($shop_status_id ? $shop_status_id : 'brak mapowania') . ')', [ 'apilo_order_id' => $order['apilo_order_id'], 'http_code' => $http_code_poll, 'response' => $responseData ] );
$orderRepo->updateApiloStatusDate( (int)$order['id'], date( 'Y-m-d H:i:s' ) );
echo '<p>Zaktualizowałem status zamówienia <b>' . $order['number'] . '</b></p>';
}
}
}
}
return true;
});
/* zapisywanie historii cen produktów */
$results = $mdb -> select( 'pp_shop_products', [ 'id', 'price_brutto', 'price_brutto_promo' ], [ 'OR' => [ 'price_history_date[!]' => date( 'Y-m-d' ), 'price_history_date' => null ], 'ORDER' => [ 'price_history_date' => 'ASC' ], 'LIMIT' => 100 ] );
foreach ( $results as $row )
{
if ( $price )
// 8. Price history
$processor->registerHandler( \Domain\CronJob\CronJobType::PRICE_HISTORY, function($payload) use ($mdb) {
$results = $mdb->select( 'pp_shop_products', [ 'id', 'price_brutto', 'price_brutto_promo' ], [ 'OR' => [ 'price_history_date[!]' => date( 'Y-m-d' ), 'price_history_date' => null ], 'ORDER' => [ 'price_history_date' => 'ASC' ], 'LIMIT' => 100 ] );
foreach ( $results as $row )
{
$mdb -> insert( 'pp_shop_product_price_history', [
'id_product' => $row['id'],
'price' => $row['price_brutto_promo'] > 0 ? $row['price_brutto_promo'] : $row['price_brutto'],
'date' => date( 'Y-m-d' )
] );
$price = $row['price_brutto_promo'] > 0 ? $row['price_brutto_promo'] : $row['price_brutto'];
if ( $price )
{
$mdb->insert( 'pp_shop_product_price_history', [
'id_product' => $row['id'],
'price' => $price,
'date' => date( 'Y-m-d' )
] );
}
$mdb->update( 'pp_shop_products', [ 'price_history_date' => date( 'Y-m-d' ) ], [ 'id' => $row['id'] ] );
$mdb->delete( 'pp_shop_product_price_history', [ 'date[<=]' => date( 'Y-m-d', strtotime( '-31 days', time() ) ) ] );
echo '<p>Zapisuję historyczną cenę dla produktu <b>#' . $row['id'] . '</b></p>';
}
$mdb -> update( 'pp_shop_products', [ 'price_history_date' => date( 'Y-m-d' ) ], [ 'id' => $row['id'] ] );
return true;
});
$mdb -> delete( 'pp_shop_product_price_history', [ 'date[<=]' => date( 'Y-m-d', strtotime( '-31 days', time() ) ) ] );
echo '<p>Zapisuję historyczną cenę dla produktu <b>#' . $row['id'] . '</b></p>';
}
/* parsowanie zamówień m.in. pod kątem najczęściej sprzedawanych razem produktów */
$orders = $mdb -> select( 'pp_shop_orders', 'id', [ 'parsed' => 0, 'LIMIT' => 1 ] );
foreach ( $orders as $order )
{
$products = $mdb -> select( 'pp_shop_order_products', 'product_id', [ 'order_id' => $order ] );
foreach ( $products as $product1 )
// 9. Order analysis
$processor->registerHandler( \Domain\CronJob\CronJobType::ORDER_ANALYSIS, function($payload) use ($mdb) {
$orders = $mdb->select( 'pp_shop_orders', 'id', [ 'parsed' => 0, 'LIMIT' => 1 ] );
foreach ( $orders as $order )
{
if ( $parent_id = $mdb -> get( 'pp_shop_products', 'parent_id', [ 'id' => $product1 ] ) )
$product1 = $parent_id;
foreach ( $products as $product2 )
$products = $mdb->select( 'pp_shop_order_products', 'product_id', [ 'order_id' => $order ] );
foreach ( $products as $product1 )
{
if ( $parent_id = $mdb -> get( 'pp_shop_products', 'parent_id', [ 'id' => $product2 ] ) )
$product2 = $parent_id;
if ( $parent_id = $mdb->get( 'pp_shop_products', 'parent_id', [ 'id' => $product1 ] ) )
$product1 = $parent_id;
if ( $product1 != $product2 )
foreach ( $products as $product2 )
{
$intersection_id = $mdb -> query( 'SELECT * FROM pp_shop_orders_products_intersection WHERE product_1_id = :product_1_id AND product_2_id = :product_2_id OR product_1_id = :product_2_id AND product_2_id = :product_1_id', [ 'product_1_id' => (int)$product1, 'product_2_id' => (int)$product2 ] ) -> fetch( \PDO::FETCH_ASSOC );
if ( $intersection_id )
if ( $parent_id = $mdb->get( 'pp_shop_products', 'parent_id', [ 'id' => $product2 ] ) )
$product2 = $parent_id;
if ( $product1 != $product2 )
{
$mdb -> update( 'pp_shop_orders_products_intersection', [ 'count' => $intersection_id['count'] + 1 ], [ 'id' => $intersection_id['id'] ] );
}
else
{
$mdb -> insert( 'pp_shop_orders_products_intersection', [ 'product_1_id' => (int)$product1, 'product_2_id' => (int)$product2, 'count' => 1 ] );
$stmt = $mdb->query( 'SELECT * FROM pp_shop_orders_products_intersection WHERE product_1_id = :product_1_id AND product_2_id = :product_2_id OR product_1_id = :product_2_id AND product_2_id = :product_1_id', [ 'product_1_id' => (int)$product1, 'product_2_id' => (int)$product2 ] );
$intersection_id = $stmt ? $stmt->fetch( \PDO::FETCH_ASSOC ) : null;
if ( $intersection_id )
{
$mdb->update( 'pp_shop_orders_products_intersection', [ 'count' => $intersection_id['count'] + 1 ], [ 'id' => $intersection_id['id'] ] );
}
else
{
$mdb->insert( 'pp_shop_orders_products_intersection', [ 'product_1_id' => (int)$product1, 'product_2_id' => (int)$product2, 'count' => 1 ] );
}
}
}
}
$mdb->update( 'pp_shop_orders', [ 'parsed' => 1 ], [ 'id' => $order ] );
echo '<p>Parsuję zamówienie <b>#' . $order . '</b></p>';
}
$mdb -> update( 'pp_shop_orders', [ 'parsed' => 1 ], [ 'id' => $order ] );
echo '<p>Parsuję zamówienie <b>#' . $order . '</b></p>';
}
return true;
});
// 10. Google XML feed
$processor->registerHandler( \Domain\CronJob\CronJobType::GOOGLE_XML_FEED, function($payload) use ($mdb) {
( new \Domain\Product\ProductRepository( $mdb ) )->generateGoogleFeedXml();
echo '<p>Wygenerowano Google XML Feed</p>';
return true;
});
// 11. TrustMate invitation — handled by separate cron-turstmate.php (requires browser context)
$processor->registerHandler( \Domain\CronJob\CronJobType::TRUSTMATE_INVITATION, function($payload) use ($config) {
if ( !isset($config['trustmate']['enabled']) || !$config['trustmate']['enabled'] ) return true;
// TrustMate requires browser context (JavaScript). Handled by cron-turstmate.php.
return true;
});
// =========================================================================
// Run processor
// =========================================================================
$result = $processor->run( 20 );
echo '<hr>';
echo '<p><small>CronJob stats: scheduled=' . $result['scheduled'] . ', processed=' . $result['processed'] . ', succeeded=' . $result['succeeded'] . ', failed=' . $result['failed'] . ', skipped=' . $result['skipped'] . '</small></p>';

View File

@@ -440,6 +440,38 @@ Odpowiedz:
}
```
### Kategorie
#### Lista kategorii
```
GET api.php?endpoint=categories&action=list
```
Zwraca plaska liste wszystkich aktywnych kategorii w domyslnym jezyku sklepu.
Odpowiedz:
```json
{
"status": "ok",
"data": {
"categories": [
{"id": 1, "parent_id": null, "title": "Kategoria glowna"},
{"id": 3, "parent_id": 1, "title": "Podkategoria A"},
{"id": 5, "parent_id": 1, "title": "Podkategoria B"}
]
}
}
```
Pola odpowiedzi:
| Pole | Typ | Opis |
|------|-----|------|
| `id` | int | ID kategorii |
| `parent_id` | int\|null | ID kategorii nadrzednej (null = kategoria glowna) |
| `title` | string | Nazwa w domyslnym jezyku; fallback na inny jezyk jesli brak tlumaczenia |
---
### Slowniki
#### Lista statusow zamowien
@@ -549,3 +581,4 @@ UPDATE pp_settings SET value = 'twoj-klucz-api' WHERE param = 'api_key';
- `OrdersApiController` — zamowienia (5 akcji)
- `ProductsApiController` — produkty (8 akcji: list, get, create, update, variants, create_variant, update_variant, delete_variant)
- `DictionariesApiController` — slowniki (5 akcji: statuses, transports, payment_methods, attributes, ensure_producer)
- `CategoriesApiController` — kategorie (1 akcja: list)

View File

@@ -4,6 +4,88 @@ Logi zmian z migracji na Domain-Driven Architecture. Najnowsze na gorze.
---
## ver. 0.330 (2026-02-27) - Eliminacja htaccess.conf — wszystkie trasy URL w pp_routes
- **REFACTOR**: `Helpers::htacces()` — generowanie `.htaccess` w całości z PHP (usunięty `file_get_contents('htaccess.conf')` i placeholder `{HTACCESS_CACHE}`)
- **NEW**: 32 statyczne trasy systemowe wstawiane do `pp_routes` z `type='system'` przy każdym `htacces()` (koszyk, logowanie, wylogowanie, panel klienta, newsletter, zamówienia, płatności, moduły AJAX: shopBasket/shopClient/shopProduct/shopCoupon/search)
- **NEW**: Dynamiczne trasy językowe i producentów (producenci + per-producent z paginacją) przenoszone do `pp_routes` zamiast `.htaccess`
- **NEW**: Kolumna `type VARCHAR(20) NULL` w `pp_routes``NULL` dla encji, `'system'` dla tras systemowych
- **REMOVED**: `libraries/htaccess.conf` — plik szablonu usunięty, treść wbudowana w PHP
- **PERF**: Invalidacja cache Redis `pp_routes:all` po każdym `htacces()` — świeże trasy przy kolejnym żądaniu
- **MIGRATION**: `migrations/0.329.sql` (dodano `type` column)
- **DOCS**: `docs/DATABASE_STRUCTURE.md` — zaktualizowana sekcja `pp_routes` o kolumnę `type`
---
## ver. 0.329 (2026-02-27) - Routing kategorii, stron i artykułów przez pp_routes
- **REFACTOR**: `index.php` — blok routingu przez `pp_routes` przeniesiony PRZED `checkUrlParams()` (poprawna kolejność: lang/a=page dostępne w checkUrlParams)
- **PERF**: Cache Redis dla tras (`pp_routes:all`, TTL 86400s) w `index.php` — jeden SELECT na 24h zamiast przy każdym żądaniu
- **NEW**: Kategorie, strony i artykuły zapisywane do `pp_routes` zamiast `.htaccess` w `Helpers::htacces()`
- **NEW**: `CategoryRepository::categoryDelete()` — usuwa powiązane `pp_routes` przed odświeżeniem
- **NEW**: `PagesRepository::pageDelete()` — usuwa powiązane `pp_routes`
- **NEW**: `ArticleRepository::archive()` i `deletePermanently()` — usuwa powiązane `pp_routes`
- **MIGRATION**: `migrations/0.329.sql``ALTER TABLE pp_routes ADD COLUMN category_id, page_id, article_id`
- **TESTS**: Zaktualizowane `CategoryRepositoryTest` i `ArticleRepositoryTest` (nowe asercje na `pp_routes` delete)
---
## ver. 0.328 (2026-02-27) - Ikona kopiowania wartości atrybutów w szczegółach zamówienia
- **NEW**: `order-details-custom-script.php` — JS parsuje `.atributes` div i wstrzykuje przycisk `fa-copy` przy każdej wartości atrybutu
- **UX**: Kliknięcie kopiuje wartość do schowka (Clipboard API + fallback execCommand), ikona zmienia się na `fa-check` z zielonym tłem przez 1,5s
---
## ver. 0.327 (2026-02-27) - Masowe usuwanie w archiwum produktów
- **NEW**: `ProductArchiveController::bulk_delete_permanent()` — endpoint POST `product_archive/bulk_delete_permanent/`, przyjmuje `ids[]`, usuwa każdy produkt przez `ProductRepository::delete()`, zwraca JSON `{success, deleted, errors[]}`
- **UX**: Kolumna checkboxów w liście archiwum produktów + pasek akcji masowych z licznikiem zaznaczonych
- **UX**: "Zaznacz wszystkie" w nagłówku tabeli (wstrzyknięty via JS), dialog potwierdzenia przed masowym usunięciem
- **TEST**: 2 nowe testy w `ProductArchiveControllerTest` — weryfikacja istnienia i sygnatury `bulk_delete_permanent`
---
## ver. 0.326 (2026-02-27) - API: endpoint categories/list
- **NEW**: `api\Controllers\CategoriesApiController` — nowy kontroler API z akcją `list`
- **NEW**: Endpoint `GET api.php?endpoint=categories&action=list` — zwraca płaską listę aktywnych kategorii (id, parent_id, title) w domyślnym języku sklepu
- **FIX**: Usunięto zbędny parametr w `CategoryRepository`, eliminacja N+1 queries w categories/list przez bulk-fetch tytułów
---
## ver. 0.325 (2026-02-27) - Fix changelog encoding + limit wyświetlania
- **FIX**: `updates/changelog.php` — naprawione krzaczki (mojibake) w polskich znakach; dane odbudowane z plików manifest
- **NEW**: `updates/changelog-data.html` — czyste dane changelog oddzielone od logiki PHP
- **REFACTOR**: `updates/changelog.php` — konwersja ze statycznego HTML na skrypt PHP: `Content-Type: utf-8`, parsowanie wpisów, filtrowanie po wersji
- **NEW**: Parametr `?ver=X.XXX` — ogranicza changelog do 5 wersji wstecz od wersji instancji
- **UPDATE**: `admin/templates/update/main-view.php` — przekazuje `?ver=` do URL changelog
- **UPDATE**: `build-update.ps1` — nowe wpisy dopisywane do `changelog-data.html` zamiast `changelog.php`
---
## ver. 0.324 (2026-02-27) - System kolejki zadań cron
- **NEW**: `Domain\CronJob\CronJobType` — stałe typów zadań, priorytetów, statusów, exponential backoff
- **NEW**: `Domain\CronJob\CronJobRepository` — CRUD na `pp_cron_jobs` + `pp_cron_schedules` (enqueue, fetchNext, markCompleted, markFailed, hasPendingJob, cleanup, recoverStuck, getDueSchedules, touchSchedule)
- **NEW**: `Domain\CronJob\CronJobProcessor` — orkiestracja: rejestracja handlerów, tworzenie scheduled jobs, przetwarzanie kolejki z priorytetami i retry/backoff
- **NEW**: Tabele `pp_cron_jobs` i `pp_cron_schedules` — kolejka zadań z priorytetami, exponential backoff, harmonogram cykliczny
- **REFACTOR**: `cron.php` — zastąpienie monolitycznego ~550 linii orkiestratorem z CronJobProcessor i zarejestrowanymi handlerami
- **REFACTOR**: `OrderAdminService::queueApiloSync()` — kolejkowanie przez `CronJobRepository::enqueue()` zamiast pliku JSON
- **REFACTOR**: `OrderAdminService::syncApiloPayment()`, `syncApiloStatus()` — zmiana z private na public (używane przez handlery cron)
- **REMOVED**: `OrderAdminService::processApiloSyncQueue()`, `loadApiloSyncQueue()`, `saveApiloSyncQueue()`, `apiloSyncQueuePath()`, stała `APILO_SYNC_QUEUE_FILE`
- **NEW**: Jednorazowa migracja JSON queue → DB w cron.php (automatyczna przy pierwszym uruchomieniu)
- **SECURITY**: `cron.php` — ochrona endpointu: wymaga `$config['cron_key']` w URL (`?key=...`) lub trybu CLI
- **FIX**: `CronJobRepository::fetchNext()` — re-SELECT po UPDATE eliminuje race condition przy równoległych workerach
- **FIX**: `cron.php` — null check dla `$mdb->query()` przed `->fetch()` / `->fetchAll()` (3 miejsca)
- **FIX**: `cron.php` — walidacja odpowiedzi curl w APILO_PRODUCT_SYNC i APILO_PRICELIST_SYNC (zapobiega zapisaniu null do bazy)
- **FIX**: DI wiring — `CronJobRepository` przekazywany do `OrderAdminService` we wszystkich 4 punktach: `admin\App`, `api\ApiRouter`, `front\App`, `cron.php`
- **TESTS**: 41 nowych testów CronJob (CronJobTypeTest, CronJobRepositoryTest, CronJobProcessorTest)
- **MIGRATION**: `migrations/0.324.sql`
---
## ver. 0.323 (2026-02-24) - Import zdjęć, trwałe usuwanie, fix API upload
- **FIX**: `IntegrationsRepository::shopproImportProduct()` — kompletny refactor importu zdjęć: walidacja HTTP response, curl timeouty, bezpieczna budowa URL, szczegółowy log do `logs/shoppro-import-debug.log` i `error_log`, czytelny komunikat z wynikiem

View File

@@ -654,3 +654,74 @@ Tlumaczenia producentow (per jezyk). FK kaskadowe ON DELETE CASCADE.
**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

@@ -16,6 +16,7 @@ Kazdy modul zawiera Repository (i opcjonalnie dodatkowe klasy). Konstruktor DI z
| 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 |
@@ -84,6 +85,7 @@ REST API dla ordersPRO. Entry point: `api.php`. Stateless (bez sesji), autentyka
- `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`

View File

@@ -23,7 +23,7 @@ composer test # standard
## Aktualny stan
```text
OK (765 tests, 2153 assertions)
OK (805 tests, 2253 assertions)
```
Zweryfikowano: 2026-02-24 (ver. 0.318)
@@ -52,6 +52,9 @@ tests/
| | |-- 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

View File

@@ -1 +1,4 @@
1. Dodać przycisk kopiowania przy atrybutach produktu w zamówieniu
1. Dodać przycisk kopiowania przy atrybutach produktu w zamówieniu
2. Poprawić htaccess, żeby w nim nie było w ogóle adresów strona wszystko z bazy.
3. Dodać uwierzytelnienie dwuskładnikowe za pomocą aplikacji.
4. Dodać zarządzanie uprawnieniami na poziomie urzytkownika, na razie uprawnienia do poszczególnych modułów.

View File

@@ -0,0 +1,658 @@
# htaccess.conf Elimination — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Eliminate `libraries/htaccess.conf` as a template file and move all remaining hardcoded URL routes into `pp_routes`, leaving only true Apache-level directives in the generated `.htaccess`.
**Architecture:** `Helpers::htacces()` generates the full `.htaccess` content from PHP strings instead of loading a template. All URL→PHP mappings (static system routes + dynamic per language/producer) are inserted into `pp_routes` with `type='system'`, deleted and reinserted on every `htacces()` call. Apache-level rules (HTTPS redirect, admin routing, thumb.php) stay in `.htaccess` only.
**Tech Stack:** PHP 7.4, Medoo ORM (`$mdb`), Redis (CacheHandler), PHPUnit 9.6
---
## Context
### Current `Helpers::htacces()` structure (before this plan)
1. Loads `libraries/htaccess.conf` template (contains many hardcoded URL routes)
2. Appends language switch rules to `$htaccess_data`
3. Appends newsletter and producer rules to `$htaccess_data`
4. Inserts category/product/page/article routes into `pp_routes` (done in v0.329)
5. Replaces `{HTACCESS_CACHE}` placeholder
6. Appends catch-all, writes files
### What stays in `.htaccess` after this plan
- `RewriteEngine On`, `RewriteBase /`, `Options`
- www→https redirect
- http→https redirect (with tpay/przelewy24/hotpay exclusion)
- Trailing slash removal (excluding `/admin/`)
- Admin routing: `^admin/([^/]*)/([^/]*)/(.*)$`
- `^admin/$`
- Thumbnail: `^thumb/([0-9]*)/([0-9]*)/(.*)$``/libraries/thumb.php` (different PHP file, cannot use pp_routes)
- `THE_REQUEST` index.php redirect
- Cache headers block (gzip/expires or no-cache based on `$settings['htaccess_cache']`)
- File protection: `<Files *.conf>`, `<Files *.log>`, `<Files *.ini>`
- Start page 301 redirects (generated dynamically in pages loop)
- Custom htaccess from `pp_settings` (param=htaccess)
- Catch-all: `RewriteRule ^ index.php [L]`
### New `type` column in `pp_routes`
- `NULL` = entity route (product/category/page/article)
- `'system'` = system route (all routes in this plan)
- On every `htacces()` call: `DELETE WHERE type='system'`, then reinsert all
---
## Task 1: Update SQL migration — add `type` column
**Files:**
- Modify: `migrations/0.329.sql`
**Step 1: Add `type` column to the migration**
Open `migrations/0.329.sql` (currently has 4 lines). Append the `type` column:
```sql
ALTER TABLE pp_routes
ADD COLUMN category_id INT NULL AFTER product_id,
ADD COLUMN page_id INT NULL AFTER category_id,
ADD COLUMN article_id INT NULL AFTER page_id,
ADD COLUMN type VARCHAR(20) NULL AFTER article_id;
```
**Step 2: Apply migration on server**
Run on the production/staging database:
```sql
ALTER TABLE pp_routes ADD COLUMN type VARCHAR(20) NULL AFTER article_id;
```
(The other 3 columns from 0.329 should already be applied from the previous deployment.)
**Step 3: No test needed** — pure schema change, verified when routes are inserted in Task 2.
---
## Task 2: Refactor `Helpers::htacces()` — replace template + move all routes to pp_routes
**Files:**
- Modify: `autoload/Shared/Helpers/Helpers.php` (method `htacces()`, lines ~408773)
This is the core task. The entire method is refactored. Here is the complete new body:
**Step 1: Replace the method body**
Find the opening of `htacces()` at line ~408. Replace everything from the start of the method body through the end (line ~773) with the code below.
The key structural changes:
- Remove `file_get_contents(htaccess.conf)` and `str_replace('{PAGE}', ...)`
- Remove `str_replace('{HTACCESS_CACHE}', ...)` — cache block is now inline
- Build `$htaccess_data` directly as PHP string
- Delete all `type='system'` routes, then reinsert static + dynamic ones
- Language switch → `pp_routes` (remove from `$htaccess_data`)
- Newsletter → `pp_routes` (remove from `$htaccess_data`)
- Producenci/producent → `pp_routes` (remove from `$htaccess_data`)
**New `htacces()` method body** — replace lines 409773 with:
```php
{
global $mdb;
$settings = ( new \Domain\Settings\SettingsRepository( $mdb ) )->allSettings( true );
$url = preg_replace( '#^(http(s)?://)?w{3}\.#', '$1', $_SERVER['SERVER_NAME'] );
$robots = 'User-agent: *' . PHP_EOL;
$robots .= 'Allow: /' . PHP_EOL;
$site_map = '<?xml version="1.0" encoding="UTF-8"?>' . PHP_EOL;
$site_map .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . PHP_EOL;
$site_map .= '<url>' . PHP_EOL;
$site_map .= '<loc>https://' . $url . '</loc>' . PHP_EOL;
$site_map .= '<lastmod>' . date( 'Y-m-d' ) . '</lastmod>' . PHP_EOL;
$site_map .= '<changefreq>daily</changefreq>' . PHP_EOL;
$site_map .= '<priority>1</priority>' . PHP_EOL;
$site_map .= '</url>' . PHP_EOL;
//
// SYSTEM ROUTES — delete all and reinsert
//
$mdb->delete( 'pp_routes', [ 'type' => 'system' ] );
// Static system routes (hardcoded, never change)
$systemRoutes = [
// Wyszukiwarka
[ 'pattern' => '^wyszukiwarka/([^/]+)/([0-9]+)$', 'destination' => 'index.php?module=search&action=search_results&query=$1&bs=$2' ],
[ 'pattern' => '^wyszukiwarka/([^/]+)$', 'destination' => 'index.php?module=search&action=search_results&query=$1&bs=1' ],
// Zamowienia
[ 'pattern' => '^zamowienie/([a-zA-Z0-9-]+)$', 'destination' => 'index.php?module=shop_order&action=order_details&order_hash=$1' ],
[ 'pattern' => '^potwierdzenie-platnosci/([a-zA-Z0-9-]+)$', 'destination' => 'index.php?module=shop_order&action=payment_confirmation&order_hash=$1' ],
// Platnosci
[ 'pattern' => '^tpay-status$', 'destination' => 'index.php?module=shop_order&action=payment_status_tpay' ],
[ 'pattern' => '^platnosc-status$', 'destination' => 'index.php?module=shop_order&action=payment_status_hotpay' ],
[ 'pattern' => '^przelewy24-status$', 'destination' => 'index.php?module=shop_order&action=payment_status_przelewy24pl' ],
// Koszyk
[ 'pattern' => '^koszyk$', 'destination' => 'index.php?module=shop_basket&action=main_view' ],
[ 'pattern' => '^koszyk-podsumowanie$', 'destination' => 'index.php?module=shop_basket&action=summary_view' ],
[ 'pattern' => '^zloz-zamowienie$', 'destination' => 'index.php?module=shop_basket&action=basket_save' ],
// Klient
[ 'pattern' => '^rejestracja$', 'destination' => 'index.php?module=shop_client&action=register_form' ],
[ 'pattern' => '^logowanie$', 'destination' => 'index.php?module=shop_client&action=login_form' ],
[ 'pattern' => '^wylogowanie$', 'destination' => 'index.php?module=shop_client&action=logout' ],
[ 'pattern' => '^odzyskiwanie-hasla$', 'destination' => 'index.php?module=shop_client&action=recover_password' ],
[ 'pattern' => '^panel-klienta/zamowienia$', 'destination' => 'index.php?module=shop_client&action=client_orders' ],
[ 'pattern' => '^panel-klienta/adresy$', 'destination' => 'index.php?module=shop_client&action=client_addresses' ],
[ 'pattern' => '^panel-klienta/nowy-adres$', 'destination' => 'index.php?module=shop_client&action=address_edit' ],
[ 'pattern' => '^panel-klienta/edytuj-adres/([0-9]+)$', 'destination' => 'index.php?module=shop_client&action=address_edit&id=$1' ],
[ 'pattern' => '^panel-klienta/usun-adres/([0-9]+)$', 'destination' => 'index.php?module=shop_client&action=address_delete&id=$1' ],
// Newsletter
[ 'pattern' => '^newsletter/signin$', 'destination' => 'index.php?module=newsletter&action=signin' ],
[ 'pattern' => '^newsletter/confirm/hash=(.+)$', 'destination' => 'index.php?module=newsletter&action=confirm&hash=$1' ],
[ 'pattern' => '^newsletter/unsubscribe/hash=(.+)$', 'destination' => 'index.php?module=newsletter&action=unsubscribe&hash=$1' ],
// Moduły AJAX (shopBasket, shopClient, shopProduct, shopCoupon, search)
[ 'pattern' => '^shopBasket/([^/]+)/(.+)$', 'destination' => 'index.php?module=shopBasket&action=$1&$2' ],
[ 'pattern' => '^shopBasket/([^/]+)$', 'destination' => 'index.php?module=shopBasket&action=$1' ],
[ 'pattern' => '^shopClient/([^/]+)/(.+)$', 'destination' => 'index.php?module=shopClient&action=$1&$2' ],
[ 'pattern' => '^shopClient/([^/]+)$', 'destination' => 'index.php?module=shopClient&action=$1' ],
[ 'pattern' => '^shopProduct/([^/]+)/(.+)$', 'destination' => 'index.php?module=shopProduct&action=$1&$2' ],
[ 'pattern' => '^shopProduct/([^/]+)$', 'destination' => 'index.php?module=shopProduct&action=$1' ],
[ 'pattern' => '^shopCoupon/([^/]+)/(.+)$', 'destination' => 'index.php?module=shopCoupon&action=$1&$2' ],
[ 'pattern' => '^shopCoupon/([^/]+)$', 'destination' => 'index.php?module=shopCoupon&action=$1' ],
[ 'pattern' => '^search/([^/]+)/(.+)$', 'destination' => 'index.php?module=search&action=$1&$2' ],
[ 'pattern' => '^search/([^/]+)$', 'destination' => 'index.php?module=search&action=$1' ],
];
foreach ( $systemRoutes as $route )
{
$mdb->insert( 'pp_routes', [
'type' => 'system',
'lang_id' => 0,
'pattern' => $route['pattern'],
'destination' => $route['destination'],
] );
}
// Dynamic system routes — languages
$results = $mdb->select( 'pp_langs', [ 'id' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
if ( is_array( $results ) ) foreach ( $results as $row )
{
$mdb->insert( 'pp_routes', [
'type' => 'system',
'lang_id' => 0,
'pattern' => '^' . $row['id'] . '$',
'destination' => 'index.php?a=change_language&id=' . $row['id'],
] );
}
// Dynamic system routes — producenci
$categoryDefaultLayoutId = ( new \Domain\Layouts\LayoutsRepository( $mdb ) )->categoryDefaultLayoutId();
$mdb->insert( 'pp_routes', [
'type' => 'system',
'lang_id' => 0,
'pattern' => '^producenci$',
'destination' => 'index.php?module=shop_producer&action=list&layout_id=' . $categoryDefaultLayoutId,
] );
$rows = $mdb->select( 'pp_shop_producer', '*', [ 'status' => 1 ] );
if ( self::is_array_fix( $rows ) ) foreach ( $rows as $row )
{
$mdb->insert( 'pp_routes', [
'type' => 'system',
'lang_id' => 0,
'pattern' => '^producent/' . self::seo( $row['name'] ) . '$',
'destination' => 'index.php?module=shop_producer&action=products&producer_id=' . $row['id'] . '&layout_id=' . $categoryDefaultLayoutId,
] );
$mdb->insert( 'pp_routes', [
'type' => 'system',
'lang_id' => 0,
'pattern' => '^producent/' . self::seo( $row['name'] ) . '/([0-9]+)$',
'destination' => 'index.php?module=shop_producer&action=products&producer_id=' . $row['id'] . '&layout_id=' . $categoryDefaultLayoutId . '&bs=$1',
] );
}
//
// HTACCESS — generuj z PHP (bez szablonu htaccess.conf)
//
$htaccess_data = 'RewriteEngine On' . PHP_EOL;
$htaccess_data .= 'RewriteBase /' . PHP_EOL;
$htaccess_data .= 'Options +FollowSymlinks' . PHP_EOL;
$htaccess_data .= 'Options -Indexes' . PHP_EOL;
$htaccess_data .= PHP_EOL;
$htaccess_data .= '# Przekierowanie z www na bez www i z http na https w jednym kroku' . PHP_EOL;
$htaccess_data .= 'RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]' . PHP_EOL;
$htaccess_data .= 'RewriteRule ^ https://%1%{REQUEST_URI} [R=301,L]' . PHP_EOL;
$htaccess_data .= PHP_EOL;
$htaccess_data .= '# Przekierowanie z http na https, jesli nie zawiera www' . PHP_EOL;
$htaccess_data .= 'RewriteCond %{HTTPS} off' . PHP_EOL;
$htaccess_data .= 'RewriteCond %{REQUEST_URI} !^/(tpay-status|platnosc-status|przelewy24-status)$ [NC]' . PHP_EOL;
$htaccess_data .= 'RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]' . PHP_EOL;
$htaccess_data .= PHP_EOL;
$htaccess_data .= '# Usuwanie koncowego slasha dla niekatalogów' . PHP_EOL;
$htaccess_data .= 'RewriteCond %{REQUEST_FILENAME} !-d' . PHP_EOL;
$htaccess_data .= 'RewriteCond %{REQUEST_URI} !^/admin/.*$ [NC]' . PHP_EOL;
$htaccess_data .= 'RewriteCond %{REQUEST_URI} (.+)/$' . PHP_EOL;
$htaccess_data .= 'RewriteRule ^ %1 [R=301,L]' . PHP_EOL;
$htaccess_data .= PHP_EOL;
$htaccess_data .= 'RewriteCond %{REQUEST_URI} !^(.*)/libraries/(.*) [NC]' . PHP_EOL;
$htaccess_data .= 'RewriteCond %{REQUEST_URI} !^(.*)/layout/(.*) [NC]' . PHP_EOL;
$htaccess_data .= 'RewriteRule ^admin/([^/]*)/([^/]*)/(.*)$ admin/index.php?module=$1&action=$2&$3 [L]' . PHP_EOL;
$htaccess_data .= PHP_EOL;
$htaccess_data .= 'RewriteRule ^admin/$ admin/index.php [L]' . PHP_EOL;
$htaccess_data .= PHP_EOL;
$htaccess_data .= 'RewriteRule ^thumb/([0-9]*)/([0-9]*)/(.*)$ /libraries/thumb.php?img=$3&w=$1&h=$2 [L]' . PHP_EOL;
$htaccess_data .= PHP_EOL;
$htaccess_data .= 'RewriteCond %{THE_REQUEST} ^[A-Z]{3,9}\ /index.php' . PHP_EOL;
$htaccess_data .= 'RewriteRule ^ /%1 [R=301,L]' . PHP_EOL;
/* cache — zastąpienie placeholdera {HTACCESS_CACHE} */
if ( $settings['htaccess_cache'] )
{
$htaccess_data .= '<IfModule mod_deflate.c>' . PHP_EOL
. 'AddOutputFilterByType DEFLATE text/html text/plain text/xml application/xml application/xhtml+xml text/css text/javascript application/javascript application/x-javascript' . PHP_EOL
. '</IfModule>' . PHP_EOL
. '<IfModule mod_headers.c>' . PHP_EOL
. 'Header set Access-Control-Allow-Origin "*"' . PHP_EOL
. '</IfModule>' . PHP_EOL
. '<IfModule mod_expires.c>' . PHP_EOL
. 'ExpiresActive on' . PHP_EOL
. 'ExpiresDefault "access plus 1 month"' . PHP_EOL
. 'ExpiresByType text/css "access plus 1 year"' . PHP_EOL
. 'ExpiresByType application/json "access plus 0 seconds"' . PHP_EOL
. 'ExpiresByType application/xml "access plus 0 seconds"' . PHP_EOL
. 'ExpiresByType text/xml "access plus 0 seconds"' . PHP_EOL
. 'ExpiresByType image/x-icon "access plus 1 week"' . PHP_EOL
. 'ExpiresByType text/x-component "access plus 1 month"' . PHP_EOL
. 'ExpiresByType text/html "access plus 0 seconds"' . PHP_EOL
. 'ExpiresByType application/javascript "access plus 1 year"' . PHP_EOL
. 'ExpiresByType application/x-web-app-manifest+json "access plus 0 seconds"' . PHP_EOL
. 'ExpiresByType text/cache-manifest "access plus 0 seconds"' . PHP_EOL
. 'ExpiresByType audio/ogg "access plus 1 month"' . PHP_EOL
. 'ExpiresByType image/gif "access plus 1 month"' . PHP_EOL
. 'ExpiresByType image/jpeg "access plus 1 month"' . PHP_EOL
. 'ExpiresByType image/png "access plus 1 month"' . PHP_EOL
. 'ExpiresByType video/mp4 "access plus 1 month"' . PHP_EOL
. 'ExpiresByType video/ogg "access plus 1 month"' . PHP_EOL
. 'ExpiresByType video/webm "access plus 1 month"' . PHP_EOL
. 'ExpiresByType application/atom+xml "access plus 1 hour"' . PHP_EOL
. 'ExpiresByType application/rss+xml "access plus 1 hour"' . PHP_EOL
. 'ExpiresByType application/font-woff "access plus 1 month"' . PHP_EOL
. 'ExpiresByType application/vnd.ms-fontobject "access plus 1 month"' . PHP_EOL
. 'ExpiresByType application/x-font-ttf "access plus 1 month"' . PHP_EOL
. 'ExpiresByType font/opentype "access plus 1 month"' . PHP_EOL
. 'ExpiresByType image/svg+xml "access plus 1 month"' . PHP_EOL
. '</IfModule>' . PHP_EOL;
}
else
{
$htaccess_data .= '<IfModule mod_headers.c>' . PHP_EOL
. 'Header set Cache-Control "no-cache, no-store, must-revalidate"' . PHP_EOL
. 'Header set Pragma "no-cache"' . PHP_EOL
. 'Header set Expires 0' . PHP_EOL
. '</IfModule>' . PHP_EOL;
}
$htaccess_data .= '<Files *.conf>' . PHP_EOL;
$htaccess_data .= ' Order Deny,Allow' . PHP_EOL;
$htaccess_data .= ' Deny from all' . PHP_EOL;
$htaccess_data .= '</Files>' . PHP_EOL;
$htaccess_data .= '<Files *.log>' . PHP_EOL;
$htaccess_data .= ' Order Deny,Allow' . PHP_EOL;
$htaccess_data .= ' Deny from all' . PHP_EOL;
$htaccess_data .= '</Files>' . PHP_EOL;
$htaccess_data .= '<Files *.ini>' . PHP_EOL;
$htaccess_data .= ' Order Deny,Allow' . PHP_EOL;
$htaccess_data .= ' Deny from all' . PHP_EOL;
$htaccess_data .= '</Files>' . PHP_EOL;
//
// KATEGORIE — sitemap + pp_routes (bez zmian)
//
$results = $mdb->select( 'pp_langs', [ 'id', 'start' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
if ( is_array( $results ) ) foreach ( $results as $row )
{
!$row['start'] ? $language_link = $row['id'] . '/' : $language_link = '';
$results2 = $mdb->select( 'pp_shop_categories_langs', [ '[><]pp_shop_categories' => [ 'category_id' => 'id' ] ], [ 'seo_link', 'title', 'category_id' ], [ 'lang_id' => $row['id'], 'ORDER' => [ 'o' => 'ASC' ] ] );
if ( is_array( $results2 ) ) foreach ( $results2 as $row2 )
{
if ( $row2['title'] )
{
$site_map .= '<url>' . PHP_EOL;
if ( $row2['seo_link'] )
$site_map .= '<loc>https://' . $url . '/' . $language_link . self::seo( $row2['seo_link'] ) . '</loc>' . PHP_EOL;
else
$site_map .= '<loc>https://' . $url . '/' . $language_link . 'k-' . $row2['category_id'] . '-' . self::seo( $row2['title'] ) . '</loc>' . PHP_EOL;
$site_map .= '<lastmod>' . date( 'Y-m-d' ) . '</lastmod>' . PHP_EOL;
$site_map .= '<changefreq>daily</changefreq>' . PHP_EOL;
$site_map .= '<priority>1</priority>' . PHP_EOL;
$site_map .= '</url>' . PHP_EOL;
$seoSlug = $row2['seo_link'] ? self::seo( $row2['seo_link'] ) : 'k-' . $row2['category_id'] . '-' . self::seo( $row2['title'] );
$mdb->delete( 'pp_routes', [ 'AND' => [ 'category_id' => $row2['category_id'], 'lang_id' => $row['id'] ] ] );
$mdb->insert( 'pp_routes', [
'category_id' => $row2['category_id'],
'lang_id' => $row['id'],
'pattern' => '^' . $language_link . $seoSlug . '$',
'destination' => 'index.php?category=' . $row2['category_id'] . '&lang=' . $row['id'],
] );
$mdb->insert( 'pp_routes', [
'category_id' => $row2['category_id'],
'lang_id' => $row['id'],
'pattern' => '^' . $language_link . $seoSlug . '/([0-9]+)$',
'destination' => 'index.php?category=' . $row2['category_id'] . '&lang=' . $row['id'] . '&bs=$1',
] );
}
}
}
//
// PRODUKTY — sitemap + pp_routes (bez zmian)
//
$results = $mdb->select( 'pp_langs', [ 'id', 'start' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
if ( is_array( $results ) )
{
foreach ( $results as $row )
{
!$row['start'] ? $language_link = $row['id'] . '/' : $language_link = '';
$results2 = $mdb->select( 'pp_shop_products_langs', [ '[><]pp_shop_products' => [ 'product_id' => 'id' ] ], [ 'seo_link', 'name', 'product_id' ], [ 'lang_id' => $row['id'], 'ORDER' => [ 'name' => 'ASC' ] ] );
if ( is_array( $results2 ) )
{
foreach ( $results2 as $row2 )
{
$mdb->delete( 'pp_routes', [ 'AND' => [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'] ] ] );
if ( $row2['name'] )
{
$site_map .= '<url>' . PHP_EOL;
if ( $row2['seo_link'] )
$site_map .= '<loc>https://' . $url . '/' . $language_link . self::seo( $row2['seo_link'] ) . '</loc>' . PHP_EOL;
else
$site_map .= '<loc>https://' . $url . '/' . $language_link . 'p-' . $row2['product_id'] . '-' . self::seo( $row2['name'] ) . '</loc>' . PHP_EOL;
$site_map .= '<lastmod>' . date( 'Y-m-d' ) . '</lastmod>' . PHP_EOL;
$site_map .= '<changefreq>daily</changefreq>' . PHP_EOL;
$site_map .= '<priority>1</priority>' . PHP_EOL;
$site_map .= '</url>' . PHP_EOL;
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' ] );
}
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' ] );
}
}
}
}
}
}
//
// STRONY + ARTYKULY — sitemap + pp_routes (bez zmian)
//
$results = $mdb->select( 'pp_langs', [ 'id', 'start' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
if ( is_array( $results ) )
foreach ( $results as $row )
{
( !$row['start'] and count( $results ) > 1 ) ? $language_link = $row['id'] . '/' : $language_link = '';
$results2 = $mdb->select( 'pp_pages_langs', [ '[><]pp_pages' => [ 'page_id' => 'id' ] ], [ 'seo_link', 'title', 'page_id', 'noindex', 'start', 'link', 'page_type' ], [ 'lang_id' => $row['id'], 'ORDER' => [ 'start' => 'DESC', 'o' => 'ASC' ] ] );
if ( is_array( $results2 ) )
foreach ( $results2 as $row2 )
{
if ( $row2['title'] and $row2['page_type'] != 3 and $row2['page_type'] != 5 )
{
if ( !$row2['noindex'] )
{
$site_map .= '<url>' . PHP_EOL;
if ( $row2['seo_link'] )
$site_map .= '<loc>https://' . $url . '/' . self::seo( $row2['seo_link'] ) . '</loc>' . PHP_EOL;
else
$site_map .= '<loc>https://' . $url . '/s-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . '</loc>' . PHP_EOL;
$site_map .= '<lastmod>' . date( 'Y-m-d' ) . '</lastmod>' . PHP_EOL;
$site_map .= '<changefreq>daily</changefreq>' . PHP_EOL;
$site_map .= '<priority>1</priority>' . PHP_EOL;
$site_map .= '</url>' . PHP_EOL;
}
else if ( $row2['noindex'] and $row2['seo_link'] )
{
$robots .= 'User-agent: GoogleBot' . PHP_EOL;
$robots .= 'Disallow: /' . $row2['seo_link'] . PHP_EOL;
}
if ( $row2['start'] )
{
if ( $row2['seo_link'] )
{
$htaccess_data .= PHP_EOL . 'RewriteCond %{REQUEST_URI} ^/' . self::seo( $row2['seo_link'] ) . '$';
$htaccess_data .= PHP_EOL . 'RewriteRule ^(.*)$ http://www.' . $url . '/' . $language_link . ' [R=permanent,L]';
$htaccess_data .= PHP_EOL . 'RewriteCond %{REQUEST_URI} ^/' . self::seo( $row2['seo_link'] ) . '-1$';
$htaccess_data .= PHP_EOL . 'RewriteRule ^(.*)$ http://www.' . $url . '/' . $language_link . ' [R=permanent,L]';
}
else
{
$htaccess_data .= PHP_EOL . 'RewriteCond %{REQUEST_URI} ^/s-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . '$';
$htaccess_data .= PHP_EOL . 'RewriteRule ^(.*)$ http://www.' . $url . '/' . $language_link . ' [R=permanent,L]';
$htaccess_data .= PHP_EOL . 'RewriteCond %{REQUEST_URI} ^/s-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . '-1$';
$htaccess_data .= PHP_EOL . 'RewriteRule ^(.*)$ http://www.' . $url . '/' . $language_link . ' [R=permanent,L]';
}
$htaccess_data .= PHP_EOL . 'RewriteRule ^$ index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'] . ' [L]';
}
$seoSlug = $row2['seo_link'] ? self::seo( $row2['seo_link'] ) : 's-' . $row2['page_id'] . '-' . self::seo( $row2['title'] );
$langPrefix = $row2['start'] ? '' : $language_link;
$mdb->delete( 'pp_routes', [ 'AND' => [ 'page_id' => $row2['page_id'], 'lang_id' => $row['id'] ] ] );
$mdb->insert( 'pp_routes', [
'page_id' => $row2['page_id'],
'lang_id' => $row['id'],
'pattern' => '^' . $langPrefix . $seoSlug . '$',
'destination' => 'index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'],
] );
$mdb->insert( 'pp_routes', [
'page_id' => $row2['page_id'],
'lang_id' => $row['id'],
'pattern' => '^' . $langPrefix . $seoSlug . '/([0-9]+)$',
'destination' => 'index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'] . '&bs=$1',
] );
}
}
$results2 = $mdb->select( 'pp_articles_langs', [ '[><]pp_articles' => [ 'article_id' => 'id' ] ], [ 'seo_link', 'title', 'article_id', 'noindex', 'copy_from' ], [ 'AND' => [ 'status' => 1, 'lang_id' => $row['id'], 'block_direct_access' => 0 ] ] );
if ( is_array( $results2 ) )
foreach ( $results2 as $row2 )
{
if ( $row2['copy_from'] != null )
{
$results_tmp = $mdb->get( 'pp_articles_langs', [ 'seo_link', 'title' ], [ 'AND' => [ 'article_id' => $row2['article_id'], 'lang_id' => $row2['copy_from'] ] ] );
$row2['seo_link'] = $results_tmp['seo_link'];
$row2['title'] = $results_tmp['title'];
}
if ( !$row2['noindex'] )
{
$site_map .= '<url>' . PHP_EOL;
if ( $row2['seo_link'] )
$site_map .= '<loc>https://' . $url . '/' . self::seo( $row2['seo_link'] ) . '</loc>' . PHP_EOL;
else
$site_map .= '<loc>https://' . $url . '/a-' . $row2['article_id'] . '-' . self::seo( $row2['title'] ) . '</loc>' . PHP_EOL;
$site_map .= '<lastmod>' . date( 'Y-m-d' ) . '</lastmod>' . PHP_EOL;
$site_map .= '<changefreq>daily</changefreq>' . PHP_EOL;
$site_map .= '<priority>1</priority>' . PHP_EOL;
$site_map .= '</url>' . PHP_EOL;
}
else if ( $row2['noindex'] and $row2['seo_link'] )
{
$robots .= 'User-agent: GoogleBot' . PHP_EOL;
$robots .= 'Disallow: /' . $row2['seo_link'] . PHP_EOL;
}
$mdb->delete( 'pp_routes', [ 'AND' => [ 'article_id' => $row2['article_id'], 'lang_id' => $row['id'] ] ] );
if ( $row2['seo_link'] )
{
$mdb->insert( 'pp_routes', [
'article_id' => $row2['article_id'],
'lang_id' => $row['id'],
'pattern' => '^' . $language_link . self::seo( $row2['seo_link'] ) . '$',
'destination' => 'index.php?article=' . $row2['article_id'] . '&lang=' . $row['id'],
] );
}
else if ( $row2['title'] != null )
{
$mdb->insert( 'pp_routes', [
'article_id' => $row2['article_id'],
'lang_id' => $row['id'],
'pattern' => '^' . $language_link . 'a-' . $row2['article_id'] . '-' . self::seo( $row2['title'] ) . '$',
'destination' => 'index.php?article=' . $row2['article_id'] . '&lang=' . $row['id'],
] );
}
}
}
// Invalidacja cache tras
try {
( new \Shared\Cache\CacheHandler() )->delete( 'pp_routes:all' );
} catch ( \Exception $e ) {
// Redis niedostepny — ignorujemy
}
$results = $mdb->get( 'pp_settings', 'value', [ 'param' => 'htaccess' ] );
if ( $results )
$htaccess_data .= PHP_EOL . $results;
$results = $mdb->get( 'pp_settings', 'value', [ 'param' => 'robots' ] );
if ( $results )
$robots .= PHP_EOL . $results;
$site_map .= '</urlset>';
$htaccess_data .= PHP_EOL;
$htaccess_data .= 'RewriteCond %{REQUEST_FILENAME} !-f' . PHP_EOL;
$htaccess_data .= 'RewriteCond %{REQUEST_FILENAME} !-d' . PHP_EOL;
$htaccess_data .= 'RewriteRule ^ index.php [L]';
// Niektore hostingi blokuja zmiane wersji PHP przez .htaccess.
$htaccess_data = preg_replace( '/^(\\s*)(AddHandler|SetHandler|ForceType)\\b/im', '$1# $2', $htaccess_data );
$fp = fopen( $dir . '.htaccess', 'w' );
fwrite( $fp, $htaccess_data );
fclose( $fp );
$fp = fopen( $dir . 'sitemap.xml', 'w' );
fwrite( $fp, $site_map );
fclose( $fp );
$fp = fopen( $dir . 'robots.txt', 'w' );
fwrite( $fp, $robots );
fclose( $fp );
}
```
**Step 2: Run tests**
```
php phpunit.phar --configuration phpunit.xml
```
Expected: all tests pass (htacces() has no unit tests, covered by integration).
---
## Task 3: Delete `libraries/htaccess.conf`
**Files:**
- Delete: `libraries/htaccess.conf`
**Step 1: Verify htacces() no longer references the file**
Search for any remaining `file_get_contents` referencing `htaccess.conf`:
```bash
grep -r "htaccess.conf" autoload/
```
Expected: no results.
**Step 2: Delete the file**
```bash
rm libraries/htaccess.conf
```
**Step 3: Run tests**
```
php phpunit.phar --configuration phpunit.xml
```
Expected: all tests still pass.
---
## Task 4: Update `docs/DATABASE_STRUCTURE.md`
**Files:**
- Modify: `docs/DATABASE_STRUCTURE.md` (section `## pp_routes`)
**Step 1: Add `type` column to the pp_routes table description**
Find the `## pp_routes` section and add the `type` row to the column table:
```markdown
| type | Typ trasy: NULL = encja (produkt/kategoria/strona/artykuł), 'system' = trasa systemowa |
```
Also update the description paragraph to mention that system routes are managed automatically.
---
## Task 5: Manual integration test on server
**Step 1: Apply migration**
```sql
ALTER TABLE pp_routes ADD COLUMN type VARCHAR(20) NULL AFTER article_id;
```
**Step 2: Trigger `htacces()` regeneration**
Log in to admin panel → save any product or category → this calls `htacces()`.
**Step 3: Verify pp_routes has system routes**
```sql
SELECT COUNT(*) FROM pp_routes WHERE type = 'system';
```
Expected: ~35+ rows (32 static + language rows + producer rows).
**Step 4: Verify .htaccess was generated correctly**
Open `.htaccess` — should NOT contain `RewriteRule ^koszyk$`, `^logowanie$`, etc. Should contain HTTPS redirect, admin routing, thumb routing, cache block.
**Step 5: Test URLs in browser**
- `/koszyk` → koszyk page ✓
- `/logowanie` → login page ✓
- `/wyszukiwarka/test` → search results ✓
- `/zamowienie/abc123` → order details ✓
- `/shopClient/confirm/hash=xyz` → client confirm action ✓
- Category URL → category page ✓
- Product URL → product page ✓
**Step 6: Run full test suite**
```
php phpunit.phar --configuration phpunit.xml
```
Expected: 807 tests, all pass.
---
## Task 6: Commit
**Step 1: Stage and commit**
```bash
git add migrations/0.329.sql
git add autoload/Shared/Helpers/Helpers.php
git add docs/DATABASE_STRUCTURE.md
git add docs/plans/2026-02-27-htaccess-conf-elimination.md
git add docs/plans/2026-02-27-htaccess-to-routes-design.md
git rm libraries/htaccess.conf
git commit -m "feat: eliminate htaccess.conf, move all routes to pp_routes (v0.330)"
```

View File

@@ -0,0 +1,121 @@
# Design: Eliminacja htaccess.conf i przeniesienie wszystkich tras do pp_routes
**Data:** 2026-02-27
**Wersja docelowa:** 0.330
---
## Cel
Wyeliminowanie pliku `libraries/htaccess.conf` jako szablonu i przeniesienie wszystkich URL-i, które dotychczas były wpisane na sztywno w `.htaccess`, do tabeli `pp_routes`. Logika generowania `.htaccess` zostaje w całości w `Helpers::htacces()`.
---
## Co zostaje w `.htaccess` (reguły Apache-level)
Tylko dyrektywy, których PHP nie może obsłużyć:
- `RewriteEngine On`, `Options`
- Redirect HTTPS/www
- Redirect HTTP→HTTPS (z wyłączeniem tpay-status, platnosc-status, przelewy24-status)
- Usuwanie trailing slash (z wyłączeniem `/admin/`)
- Routing `/admin/``admin/index.php`
- `thumb/([0-9]*)/([0-9]*)/(.*)``/libraries/thumb.php` (inny plik PHP — niemożliwe przez pp_routes)
- `RewriteCond %{THE_REQUEST} ^[A-Z]{3,9}\ /index.php` — redirect z index.php
- Blok cache headers (gzip, expires) — zależny od `$settings['htaccess_cache']`
- Ochrona plików: `<Files *.conf>`, `<Files *.log>`, `<Files *.ini>`
- Przekierowania 301 stron startowych (generowane dynamicznie w pętli pages)
- Niestandardowe reguły z `pp_settings` (param=htaccess)
- Catch-all: `RewriteCond !-f`, `!-d`, `RewriteRule ^ index.php [L]`
---
## Co przechodzi do `pp_routes`
### Statyczne trasy systemowe (hardcoded, niezmienne)
| Pattern | Destination |
|---------|-------------|
| `^wyszukiwarka/([^/]+)/([0-9]+)$` | `index.php?module=search&action=search_results&query=$1&bs=$2` |
| `^wyszukiwarka/([^/]+)$` | `index.php?module=search&action=search_results&query=$1&bs=1` |
| `^zamowienie/([a-zA-Z0-9-]+)$` | `index.php?module=shop_order&action=order_details&order_hash=$1` |
| `^potwierdzenie-platnosci/([a-zA-Z0-9-]+)$` | `index.php?module=shop_order&action=payment_confirmation&order_hash=$1` |
| `^tpay-status$` | `index.php?module=shop_order&action=payment_status_tpay` |
| `^platnosc-status$` | `index.php?module=shop_order&action=payment_status_hotpay` |
| `^przelewy24-status$` | `index.php?module=shop_order&action=payment_status_przelewy24pl` |
| `^koszyk$` | `index.php?module=shop_basket&action=main_view` |
| `^koszyk-podsumowanie$` | `index.php?module=shop_basket&action=summary_view` |
| `^zloz-zamowienie$` | `index.php?module=shop_basket&action=basket_save` |
| `^rejestracja$` | `index.php?module=shop_client&action=register_form` |
| `^logowanie$` | `index.php?module=shop_client&action=login_form` |
| `^wylogowanie$` | `index.php?module=shop_client&action=logout` |
| `^odzyskiwanie-hasla$` | `index.php?module=shop_client&action=recover_password` |
| `^panel-klienta/zamowienia$` | `index.php?module=shop_client&action=client_orders` |
| `^panel-klienta/adresy$` | `index.php?module=shop_client&action=client_addresses` |
| `^panel-klienta/nowy-adres$` | `index.php?module=shop_client&action=address_edit` |
| `^panel-klienta/edytuj-adres/([0-9]+)$` | `index.php?module=shop_client&action=address_edit&id=$1` |
| `^panel-klienta/usun-adres/([0-9]+)$` | `index.php?module=shop_client&action=address_delete&id=$1` |
| `^newsletter/signin$` | `index.php?module=newsletter&action=signin` |
| `^newsletter/confirm/hash=(.+)$` | `index.php?module=newsletter&action=confirm&hash=$1` |
| `^newsletter/unsubscribe/hash=(.+)$` | `index.php?module=newsletter&action=unsubscribe&hash=$1` |
### Trasy modułów AJAX (shopBasket, shopClient, shopProduct, shopCoupon, search)
Dwa wzorce na moduł — 3-segmentowy (z parametrami) i 2-segmentowy:
| Pattern | Destination |
|---------|-------------|
| `^shopBasket/([^/]+)/(.+)$` | `index.php?module=shopBasket&action=$1&$2` |
| `^shopBasket/([^/]+)$` | `index.php?module=shopBasket&action=$1` |
| `^shopClient/([^/]+)/(.+)$` | `index.php?module=shopClient&action=$1&$2` |
| `^shopClient/([^/]+)$` | `index.php?module=shopClient&action=$1` |
| `^shopProduct/([^/]+)/(.+)$` | `index.php?module=shopProduct&action=$1&$2` |
| `^shopProduct/([^/]+)$` | `index.php?module=shopProduct&action=$1` |
| `^shopCoupon/([^/]+)/(.+)$` | `index.php?module=shopCoupon&action=$1&$2` |
| `^shopCoupon/([^/]+)$` | `index.php?module=shopCoupon&action=$1` |
| `^search/([^/]+)/(.+)$` | `index.php?module=search&action=$1&$2` |
| `^search/([^/]+)$` | `index.php?module=search&action=$1` |
### Dynamiczne trasy systemowe (wstawiane przy każdym `htacces()`)
- **Języki:** `^{lang_id}$``index.php?a=change_language&id={lang_id}` (per każdy aktywny język)
- **Producenci lista:** `^producenci$``index.php?module=shop_producer&action=list&layout_id={id}`
- **Producent detail:** `^producent/{slug}$` i `^producent/{slug}/([0-9]+)$` (per producent z DB)
---
## Nowa kolumna `type` w `pp_routes`
```sql
ADD COLUMN type VARCHAR(20) NULL AFTER article_id
```
| Wartość | Znaczenie |
|---------|-----------|
| `NULL` | Trasa encji (produkt, kategoria, strona, artykuł) |
| `'system'` | Trasa systemowa (wszystkie powyższe) |
**Zarządzanie:** przy każdym `htacces()`:
```php
$mdb->delete('pp_routes', ['type' => 'system']); // usuń wszystkie
// ... wstaw na nowo (statyczne + dynamiczne)
```
---
## Eliminacja `htaccess.conf`
`file_get_contents($dir . 'libraries/htaccess.conf')` zastąpione PHP stringiem z tą samą treścią (tylko Apache-level reguły). Placeholder `{HTACCESS_CACHE}` zastąpiony bezpośrednim `if ($settings['htaccess_cache']) { ... } else { ... }` wbudowanym w odpowiednim miejscu.
Plik `libraries/htaccess.conf` zostaje usunięty.
---
## Pliki do modyfikacji
| Plik | Zmiana |
|------|--------|
| `migrations/0.329.sql` | Dodać `ADD COLUMN type VARCHAR(20) NULL` |
| `Helpers::htacces()` | Usunąć `file_get_contents`, wbudować statyczny header, dodać inserty system routes, usunąć htaccess rules dla języków/newsletter/producenci |
| `libraries/htaccess.conf` | Usunąć plik |
| `docs/DATABASE_STRUCTURE.md` | Dodać kolumnę `type` do opisu pp_routes |

View File

@@ -59,6 +59,49 @@ $mdb = new medoo( [
'time_debug' => $database['time_debug']
] );
// check routes
$parsed_url = parse_url($_SERVER['REQUEST_URI']);
$request_uri = ltrim($parsed_url['path'], '/');
$query_string = isset($parsed_url['query']) ? $parsed_url['query'] : '';
parse_str($query_string, $query_params);
if ($request_uri != '')
{
$cache = new \Shared\Cache\CacheHandler();
$cacheKey = 'pp_routes:all';
$routesCached = $cache->get($cacheKey);
if ($routesCached === false || $routesCached === null)
{
$routes = $mdb->select('pp_routes', '*');
$cache->set($cacheKey, $routes, 86400);
}
else
{
$routes = unserialize($routesCached);
}
foreach ($routes as $route)
{
$pattern = $route['pattern'];
$destination = $route['destination'];
if (preg_match("#^" . $pattern . "#", $request_uri, $matches))
{
// Replace placeholders in the destination with matches from the request URI
$destination = preg_replace("#^" . $pattern . "#", $destination, $request_uri);
// Parse the destination string to extract GET parameters
parse_str(parse_url($destination, PHP_URL_QUERY), $destination_params);
// Merge the destination params with query params from the URL
$_GET = array_merge($destination_params, $query_params);
break;
}
}
}
\front\App::checkUrlParams();
$langRepo = new \Domain\Languages\LanguagesRepository( $mdb );
@@ -99,39 +142,6 @@ if ( $request_uri != '' )
}
}
// check routes
$parsed_url = parse_url($_SERVER['REQUEST_URI']);
$request_uri = ltrim($parsed_url['path'], '/');
$query_string = isset($parsed_url['query']) ? $parsed_url['query'] : '';
parse_str($query_string, $query_params);
if ($request_uri != '')
{
$matched = false;
$routes = $mdb->select('pp_routes', '*');
foreach ($routes as $route)
{
$pattern = $route['pattern'];
$destination = $route['destination'];
if (preg_match("#^" . $pattern . "#", $request_uri, $matches))
{
// Replace placeholders in the destination with matches from the request URI
$destination = preg_replace("#^" . $pattern . "#", $destination, $request_uri);
// Parse the destination string to extract GET parameters
parse_str(parse_url($destination, PHP_URL_QUERY), $destination_params);
// Merge the destination params with query params from the URL
$_GET = array_merge($destination_params, $query_params);
$matched = true;
break;
}
}
}
$pagesRepo = new \Domain\Pages\PagesRepository( $mdb );
if ( \Shared\Helpers\Helpers::get( 'a' ) == 'page' and \Shared\Helpers\Helpers::get( 'id' ) )

View File

@@ -1,84 +0,0 @@
RewriteEngine On
RewriteBase /
Options +FollowSymlinks
Options -Indexes
# Przekierowanie z www na bez www i z http na https w jednym kroku
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^ https://%1%{REQUEST_URI} [R=301,L]
# Przekierowanie z http na https, jeśli nie zawiera www
RewriteCond %{HTTPS} off
RewriteCond %{REQUEST_URI} !^/(tpay-status|platnosc-status|przelewy24-status)$ [NC]
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Usuwanie końcowego slash'a dla niekatalogów
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} !^/admin/.*$ [NC] # Wyklucza ścieżki rozpoczynające się od "admin/"
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [R=301,L]
RewriteCond %{REQUEST_URI} !^(.*)/libraries/(.*) [NC]
RewriteCond %{REQUEST_URI} !^(.*)/layout/(.*) [NC]
RewriteRule ^admin/([^/]*)/([^/]*)/(.*)$ admin/index.php?module=$1&action=$2&$3 [L]
RewriteRule ^admin/$ admin/index.php [L]
RewriteRule ^wyszukiwarka/(.*)/([0-9]*)$ index.php?module=search&action=search_results&query=$1&bs=$2 [L]
RewriteRule ^wyszukiwarka/(.*)$ index.php?module=search&action=search_results&query=$1&bs=1 [L]
RewriteRule ^zamowienie/([a-zA-Z0-9-]*)$ index.php?module=shop_order&action=order_details&order_hash=$1 [L]
RewriteRule ^potwierdzenie-platnosci/([a-zA-Z0-9-]*)$ index.php?module=shop_order&action=payment_confirmation&order_hash=$1 [L]
RewriteRule ^tpay-status$ index.php?module=shop_order&action=payment_status_tpay [QSA,L]
RewriteRule ^platnosc-status$ index.php?module=shop_order&action=payment_status_hotpay [QSA,L]
RewriteRule ^przelewy24-status$ index.php?module=shop_order&action=payment_status_przelewy24pl [QSA,L]
RewriteRule ^koszyk$ index.php?module=shop_basket&action=main_view [L]
RewriteRule ^koszyk-podsumowanie$ index.php?module=shop_basket&action=summary_view [L]
RewriteRule ^zloz-zamowienie$ index.php?module=shop_basket&action=basket_save [L]
RewriteRule ^rejestracja$ index.php?module=shop_client&action=register_form [L]
RewriteRule ^logowanie$ index.php?module=shop_client&action=login_form [L]
RewriteRule ^wylogowanie$ index.php?module=shop_client&action=logout [L]
RewriteRule ^odzyskiwanie-hasla$ index.php?module=shop_client&action=recover_password [L]
RewriteRule ^panel-klienta/zamowienia$ index.php?module=shop_client&action=client_orders [L]
RewriteRule ^panel-klienta/adresy$ index.php?module=shop_client&action=client_addresses [L]
RewriteRule ^panel-klienta/nowy-adres$ index.php?module=shop_client&action=address_edit [L]
RewriteRule ^panel-klienta/edytuj-adres/([0-9]*)$ index.php?module=shop_client&action=address_edit&id=$1 [L]
RewriteRule ^panel-klienta/usun-adres/([0-9]*)$ index.php?module=shop_client&action=address_delete&id=$1 [L]
RewriteRule ^thumb/([0-9]*)/([0-9]*)/(.*)$ /libraries/thumb.php?img=$3&w=$1&h=$2 [L]
RewriteCond %{REQUEST_URI} ^/shopBasket/(.*)/(.*) [NC]
RewriteRule ^([^/]*)/([^/]*)/(.*)$ index.php?module=$1&action=$2&$3 [L]
RewriteCond %{REQUEST_URI} ^/shopClient/(.*)/(.*) [NC]
RewriteRule ^([^/]*)/([^/]*)/(.*)$ index.php?module=$1&action=$2&$3 [L]
RewriteCond %{REQUEST_URI} ^/shopProduct/(.*)/(.*) [NC]
RewriteRule ^([^/]*)/([^/]*)/(.*)$ index.php?module=$1&action=$2&$3 [L]
RewriteCond %{REQUEST_URI} ^/shopCoupon/(.*)/(.*) [NC]
RewriteRule ^([^/]*)/([^/]*)/(.*)$ index.php?module=$1&action=$2&$3 [L]
RewriteCond %{REQUEST_URI} ^/search/(.*)/(.*) [NC]
RewriteRule ^([^/]*)/([^/]*)/(.*)$ index.php?module=$1&action=$2&$3 [L]
RewriteCond %{REQUEST_URI} ^/shopBasket/(.*) [NC]
RewriteRule ^([^/]*)/([^/]*)$ index.php?module=$1&action=$2 [L]
RewriteCond %{REQUEST_URI} ^/shopClient/(.*) [NC]
RewriteRule ^([^/]*)/([^/]*)$ index.php?module=$1&action=$2 [L]
RewriteCond %{REQUEST_URI} ^/shopProduct/(.*) [NC]
RewriteRule ^([^/]*)/([^/]*)$ index.php?module=$1&action=$2 [L]
RewriteCond %{REQUEST_URI} ^/shopCoupon/(.*) [NC]
RewriteRule ^([^/]*)/([^/]*)$ index.php?module=$1&action=$2 [L]
RewriteCond %{REQUEST_URI} ^/search/(.*) [NC]
RewriteRule ^([^/]*)/([^/]*)$ index.php?module=$1&action=$2 [L]
RewriteCond %{THE_REQUEST} ^[A-Z]{3,9}\ /index.php
RewriteRule ^ /%1 [R=301,L]
{HTACCESS_CACHE}
<Files *.conf>
Order Deny,Allow
Deny from all
</Files>
<Files *.log>
Order Deny,Allow
Deny from all
</Files>
<Files *.ini>
Order Deny,Allow
Deny from all
</Files>

View File

@@ -1,6 +0,0 @@
2024-01-05 08:31:37 | 157 | 2024/01/001 | 12.30 | pyziak84@gmail.com
<pre></pre>
2024-01-05 08:36:54 | 157 | 2024/01/001 | 12.30 | pyziak84@gmail.com
{"id":516}

View File

@@ -1,6 +0,0 @@
2024-08-20 19:28:32 | 159 | 2024/08/002 | 70.99 | pyziak84@gmail.com
[]
2024-08-20 19:42:45 | 160 | 2024/08/003 | 59.99 | pyziak84@gmail.com
{"updates":1}

View File

48
migrations/0.324.sql Normal file
View File

@@ -0,0 +1,48 @@
-- System kolejki zadań cron
-- Wersja: 0.324
CREATE TABLE IF NOT EXISTS 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,
payload TEXT NULL,
result TEXT NULL,
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;
CREATE TABLE IF NOT EXISTS 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;
-- Harmonogramy zadań
INSERT INTO pp_cron_schedules (job_type, interval_seconds, priority, max_attempts) VALUES
('apilo_token_keepalive', 240, 10, 3),
('apilo_send_order', 60, 40, 10),
('apilo_product_sync', 600, 100, 3),
('apilo_pricelist_sync', 3600, 100, 3),
('apilo_status_poll', 600, 100, 3),
('price_history', 86400, 100, 3),
('order_analysis', 600, 100, 3),
('trustmate_invitation', 600, 200, 3),
('google_xml_feed', 3600, 200, 3);

5
migrations/0.329.sql Normal file
View File

@@ -0,0 +1,5 @@
ALTER TABLE pp_routes
ADD COLUMN category_id INT NULL AFTER product_id,
ADD COLUMN page_id INT NULL AFTER category_id,
ADD COLUMN article_id INT NULL AFTER page_id,
ADD COLUMN type VARCHAR(20) NULL AFTER article_id;

BIN
templates/.DS_Store vendored

Binary file not shown.

View File

@@ -513,7 +513,7 @@ class ArticleRepositoryTest extends TestCase
$mockDb = $this->createMock(\medoo::class);
$deleteCalls = [];
$mockDb->expects($this->exactly(5))
$mockDb->expects($this->exactly(6))
->method('delete')
->willReturnCallback(function ($table, $where) use (&$deleteCalls) {
$deleteCalls[] = ['table' => $table, 'where' => $where];
@@ -524,12 +524,13 @@ class ArticleRepositoryTest extends TestCase
$result = $repository->deletePermanently(77);
$this->assertTrue($result);
$this->assertCount(5, $deleteCalls);
$this->assertCount(6, $deleteCalls);
$this->assertSame('pp_articles_pages', $deleteCalls[0]['table']);
$this->assertSame('pp_articles_langs', $deleteCalls[1]['table']);
$this->assertSame('pp_articles_images', $deleteCalls[2]['table']);
$this->assertSame('pp_articles_files', $deleteCalls[3]['table']);
$this->assertSame('pp_articles', $deleteCalls[4]['table']);
$this->assertSame('pp_routes', $deleteCalls[4]['table']);
$this->assertSame('pp_articles', $deleteCalls[5]['table']);
}
public function testPagesSummaryForArticlesBuildsLabels(): void

View File

@@ -175,14 +175,19 @@ class CategoryRepositoryTest extends TestCase
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('count')->willReturn(0);
$mockDb->expects($this->once())
$deleteCalls = [];
$mockDb->expects($this->exactly(2))
->method('delete')
->with('pp_shop_categories', ['id' => 8])
->willReturn(true);
->willReturnCallback(function ($table, $where) use (&$deleteCalls) {
$deleteCalls[] = ['table' => $table, 'where' => $where];
return true;
});
$repository = new CategoryRepository($mockDb);
$this->assertTrue($repository->categoryDelete(8));
$this->assertSame('pp_shop_categories', $deleteCalls[0]['table']);
$this->assertSame('pp_routes', $deleteCalls[1]['table']);
}
public function testCategoryTitleReturnsEmptyWhenNotFound(): void

View File

@@ -0,0 +1,301 @@
<?php
namespace Tests\Unit\Domain\CronJob;
use Domain\CronJob\CronJobProcessor;
use Domain\CronJob\CronJobRepository;
use Domain\CronJob\CronJobType;
use PHPUnit\Framework\TestCase;
class CronJobProcessorTest extends TestCase
{
/** @var \PHPUnit\Framework\MockObject\MockObject|CronJobRepository */
private $mockRepo;
/** @var CronJobProcessor */
private $processor;
protected function setUp(): void
{
$this->mockRepo = $this->createMock(CronJobRepository::class);
$this->processor = new CronJobProcessor($this->mockRepo);
}
// --- registerHandler ---
public function testRegisterHandlerAndProcessJob(): void
{
$handlerCalled = false;
$this->processor->registerHandler('test_job', function ($payload) use (&$handlerCalled) {
$handlerCalled = true;
return true;
});
$this->mockRepo->method('fetchNext')->willReturn([
['id' => 1, 'job_type' => 'test_job', 'payload' => null, 'attempts' => 1],
]);
$this->mockRepo->expects($this->once())->method('markCompleted')->with(1, null);
$stats = $this->processor->processQueue(1);
$this->assertTrue($handlerCalled);
$this->assertSame(1, $stats['processed']);
$this->assertSame(1, $stats['succeeded']);
$this->assertSame(0, $stats['failed']);
}
// --- processQueue ---
public function testProcessQueueReturnsEmptyStatsWhenNoJobs(): void
{
$this->mockRepo->method('fetchNext')->willReturn([]);
$stats = $this->processor->processQueue(5);
$this->assertSame(0, $stats['processed']);
$this->assertSame(0, $stats['succeeded']);
$this->assertSame(0, $stats['failed']);
$this->assertSame(0, $stats['skipped']);
}
public function testProcessQueueHandlerReturnsFalse(): void
{
$this->processor->registerHandler('fail_job', function ($payload) {
return false;
});
$this->mockRepo->method('fetchNext')->willReturn([
['id' => 2, 'job_type' => 'fail_job', 'payload' => null, 'attempts' => 1],
]);
$this->mockRepo->expects($this->once())->method('markFailed')
->with(2, 'Handler returned false', 1);
$stats = $this->processor->processQueue(1);
$this->assertSame(1, $stats['failed']);
$this->assertSame(0, $stats['succeeded']);
}
public function testProcessQueueHandlerThrowsException(): void
{
$this->processor->registerHandler('error_job', function ($payload) {
throw new \RuntimeException('Connection failed');
});
$this->mockRepo->method('fetchNext')->willReturn([
['id' => 3, 'job_type' => 'error_job', 'payload' => null, 'attempts' => 2],
]);
$this->mockRepo->expects($this->once())->method('markFailed')
->with(3, 'Connection failed', 2);
$stats = $this->processor->processQueue(1);
$this->assertSame(1, $stats['failed']);
}
public function testProcessQueueNoHandlerRegistered(): void
{
$this->mockRepo->method('fetchNext')->willReturn([
['id' => 4, 'job_type' => 'unknown_job', 'payload' => null, 'attempts' => 1],
]);
$this->mockRepo->expects($this->once())->method('markFailed')
->with(4, $this->stringContains('No handler registered'), 1);
$stats = $this->processor->processQueue(1);
$this->assertSame(1, $stats['skipped']);
}
public function testProcessQueueHandlerReturnsArray(): void
{
$resultData = ['synced' => true, 'items' => 5];
$this->processor->registerHandler('array_job', function ($payload) use ($resultData) {
return $resultData;
});
$this->mockRepo->method('fetchNext')->willReturn([
['id' => 5, 'job_type' => 'array_job', 'payload' => null, 'attempts' => 1],
]);
$this->mockRepo->expects($this->once())->method('markCompleted')
->with(5, $resultData);
$stats = $this->processor->processQueue(1);
$this->assertSame(1, $stats['succeeded']);
}
public function testProcessQueuePassesPayloadToHandler(): void
{
$receivedPayload = null;
$this->processor->registerHandler('payload_job', function ($payload) use (&$receivedPayload) {
$receivedPayload = $payload;
return true;
});
$this->mockRepo->method('fetchNext')->willReturn([
['id' => 6, 'job_type' => 'payload_job', 'payload' => ['order_id' => 42], 'attempts' => 1],
]);
$this->mockRepo->method('markCompleted');
$this->processor->processQueue(1);
$this->assertSame(['order_id' => 42], $receivedPayload);
}
public function testProcessQueueMultipleJobs(): void
{
$this->processor->registerHandler('ok_job', function ($payload) {
return true;
});
$this->processor->registerHandler('fail_job', function ($payload) {
return false;
});
$this->mockRepo->method('fetchNext')->willReturn([
['id' => 10, 'job_type' => 'ok_job', 'payload' => null, 'attempts' => 1],
['id' => 11, 'job_type' => 'fail_job', 'payload' => null, 'attempts' => 1],
['id' => 12, 'job_type' => 'ok_job', 'payload' => null, 'attempts' => 1],
]);
$stats = $this->processor->processQueue(10);
$this->assertSame(3, $stats['processed']);
$this->assertSame(2, $stats['succeeded']);
$this->assertSame(1, $stats['failed']);
}
// --- createScheduledJobs ---
public function testCreateScheduledJobsFromDueSchedules(): void
{
$this->mockRepo->method('getDueSchedules')->willReturn([
[
'id' => 1,
'job_type' => 'price_history',
'interval_seconds' => 86400,
'priority' => 100,
'max_attempts' => 3,
'payload' => null,
],
]);
$this->mockRepo->method('hasPendingJob')->willReturn(false);
$this->mockRepo->expects($this->once())->method('enqueue')
->with('price_history', null, 100, 3);
$this->mockRepo->expects($this->once())->method('touchSchedule')
->with(1, 86400);
$created = $this->processor->createScheduledJobs();
$this->assertSame(1, $created);
}
public function testCreateScheduledJobsSkipsDuplicates(): void
{
$this->mockRepo->method('getDueSchedules')->willReturn([
[
'id' => 2,
'job_type' => 'apilo_send_order',
'interval_seconds' => 60,
'priority' => 50,
'max_attempts' => 10,
'payload' => null,
],
]);
$this->mockRepo->method('hasPendingJob')->willReturn(true);
$this->mockRepo->expects($this->never())->method('enqueue');
// touchSchedule still called to prevent re-checking
$this->mockRepo->expects($this->once())->method('touchSchedule');
$created = $this->processor->createScheduledJobs();
$this->assertSame(0, $created);
}
public function testCreateScheduledJobsWithPayload(): void
{
$this->mockRepo->method('getDueSchedules')->willReturn([
[
'id' => 3,
'job_type' => 'custom_job',
'interval_seconds' => 600,
'priority' => 100,
'max_attempts' => 3,
'payload' => '{"key":"value"}',
],
]);
$this->mockRepo->method('hasPendingJob')->willReturn(false);
$this->mockRepo->expects($this->once())->method('enqueue')
->with('custom_job', ['key' => 'value'], 100, 3);
$this->processor->createScheduledJobs();
}
public function testCreateScheduledJobsReturnsZeroWhenNoSchedules(): void
{
$this->mockRepo->method('getDueSchedules')->willReturn([]);
$created = $this->processor->createScheduledJobs();
$this->assertSame(0, $created);
}
// --- run ---
public function testRunExecutesFullPipeline(): void
{
$this->mockRepo->expects($this->once())->method('recoverStuck')->with(30);
$this->mockRepo->method('getDueSchedules')->willReturn([]);
$this->mockRepo->method('fetchNext')->willReturn([]);
$this->mockRepo->expects($this->once())->method('cleanup')->with(30);
$stats = $this->processor->run(20);
$this->assertArrayHasKey('scheduled', $stats);
$this->assertArrayHasKey('processed', $stats);
$this->assertArrayHasKey('succeeded', $stats);
$this->assertArrayHasKey('failed', $stats);
$this->assertArrayHasKey('skipped', $stats);
}
public function testRunReturnsScheduledCount(): void
{
$this->mockRepo->method('getDueSchedules')->willReturn([
[
'id' => 1,
'job_type' => 'job_a',
'interval_seconds' => 60,
'priority' => 100,
'max_attempts' => 3,
'payload' => null,
],
[
'id' => 2,
'job_type' => 'job_b',
'interval_seconds' => 120,
'priority' => 100,
'max_attempts' => 3,
'payload' => null,
],
]);
$this->mockRepo->method('hasPendingJob')->willReturn(false);
$this->mockRepo->method('fetchNext')->willReturn([]);
$stats = $this->processor->run(20);
$this->assertSame(2, $stats['scheduled']);
}
}

View File

@@ -0,0 +1,385 @@
<?php
namespace Tests\Unit\Domain\CronJob;
use Domain\CronJob\CronJobRepository;
use Domain\CronJob\CronJobType;
use PHPUnit\Framework\TestCase;
class CronJobRepositoryTest extends TestCase
{
/** @var \PHPUnit\Framework\MockObject\MockObject|\medoo */
private $mockDb;
/** @var CronJobRepository */
private $repo;
protected function setUp(): void
{
$this->mockDb = $this->createMock(\medoo::class);
$this->repo = new CronJobRepository($this->mockDb);
}
// --- enqueue ---
public function testEnqueueInsertsJobAndReturnsId(): void
{
$this->mockDb->expects($this->once())
->method('insert')
->with(
'pp_cron_jobs',
$this->callback(function ($data) {
return $data['job_type'] === 'apilo_send_order'
&& $data['status'] === 'pending'
&& $data['priority'] === 50
&& $data['max_attempts'] === 10
&& isset($data['scheduled_at']);
})
);
$this->mockDb->method('id')->willReturn('42');
$id = $this->repo->enqueue('apilo_send_order', null, CronJobType::PRIORITY_HIGH);
$this->assertSame(42, $id);
}
public function testEnqueueWithPayloadEncodesJson(): void
{
$payload = ['order_id' => 123, 'action' => 'sync'];
$this->mockDb->expects($this->once())
->method('insert')
->with(
'pp_cron_jobs',
$this->callback(function ($data) use ($payload) {
return $data['payload'] === json_encode($payload);
})
);
$this->mockDb->method('id')->willReturn('1');
$this->repo->enqueue('apilo_sync_payment', $payload);
}
public function testEnqueueWithoutPayloadDoesNotSetPayloadKey(): void
{
$this->mockDb->expects($this->once())
->method('insert')
->with(
'pp_cron_jobs',
$this->callback(function ($data) {
return !array_key_exists('payload', $data);
})
);
$this->mockDb->method('id')->willReturn('1');
$this->repo->enqueue('price_history');
}
public function testEnqueueWithScheduledAt(): void
{
$scheduled = '2026-03-01 10:00:00';
$this->mockDb->expects($this->once())
->method('insert')
->with(
'pp_cron_jobs',
$this->callback(function ($data) use ($scheduled) {
return $data['scheduled_at'] === $scheduled;
})
);
$this->mockDb->method('id')->willReturn('1');
$this->repo->enqueue('price_history', null, CronJobType::PRIORITY_NORMAL, 10, $scheduled);
}
public function testEnqueueReturnsNullOnFailure(): void
{
$this->mockDb->method('insert');
$this->mockDb->method('id')->willReturn(null);
$id = $this->repo->enqueue('test_job');
$this->assertNull($id);
}
// --- fetchNext ---
public function testFetchNextReturnsEmptyArrayWhenNoJobs(): void
{
$this->mockDb->method('select')->willReturn([]);
$result = $this->repo->fetchNext(5);
$this->assertSame([], $result);
}
public function testFetchNextUpdatesStatusToProcessing(): void
{
$pendingJobs = [
['id' => 1, 'job_type' => 'test', 'status' => 'pending', 'payload' => null],
['id' => 2, 'job_type' => 'test2', 'status' => 'pending', 'payload' => '{"x":1}'],
];
$claimedJobs = [
['id' => 1, 'job_type' => 'test', 'status' => 'processing', 'payload' => null],
['id' => 2, 'job_type' => 'test2', 'status' => 'processing', 'payload' => '{"x":1}'],
];
$this->mockDb->method('select')
->willReturnOnConsecutiveCalls($pendingJobs, $claimedJobs);
$this->mockDb->expects($this->once())
->method('update')
->with(
'pp_cron_jobs',
$this->callback(function ($data) {
return $data['status'] === 'processing'
&& isset($data['started_at']);
}),
$this->callback(function ($where) {
return $where['id'] === [1, 2]
&& $where['status'] === 'pending';
})
);
$result = $this->repo->fetchNext(5);
$this->assertCount(2, $result);
$this->assertSame('processing', $result[0]['status']);
$this->assertSame('processing', $result[1]['status']);
}
public function testFetchNextDecodesPayloadJson(): void
{
$jobs = [
['id' => 1, 'job_type' => 'test', 'status' => 'pending', 'payload' => '{"order_id":99}'],
];
$this->mockDb->method('select')->willReturn($jobs);
$this->mockDb->method('update');
$result = $this->repo->fetchNext(1);
$this->assertSame(['order_id' => 99], $result[0]['payload']);
}
// --- markCompleted ---
public function testMarkCompletedUpdatesStatus(): void
{
$this->mockDb->expects($this->once())
->method('update')
->with(
'pp_cron_jobs',
$this->callback(function ($data) {
return $data['status'] === 'completed'
&& isset($data['completed_at']);
}),
['id' => 5]
);
$this->repo->markCompleted(5);
}
public function testMarkCompletedWithResult(): void
{
$result = ['synced' => true, 'count' => 3];
$this->mockDb->expects($this->once())
->method('update')
->with(
'pp_cron_jobs',
$this->callback(function ($data) use ($result) {
return $data['result'] === json_encode($result);
}),
['id' => 7]
);
$this->repo->markCompleted(7, $result);
}
// --- markFailed ---
public function testMarkFailedWithRetriesLeft(): void
{
// Job with attempts < max_attempts → reschedule with backoff
$this->mockDb->method('get')->willReturn([
'max_attempts' => 10,
'attempts' => 2,
]);
$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' => 3]
);
$this->repo->markFailed(3, 'Connection timeout', 2);
}
public function testMarkFailedWhenMaxAttemptsReached(): void
{
// Job with attempts >= max_attempts → permanent failure
$this->mockDb->method('get')->willReturn([
'max_attempts' => 3,
'attempts' => 3,
]);
$this->mockDb->expects($this->once())
->method('update')
->with(
'pp_cron_jobs',
$this->callback(function ($data) {
return $data['status'] === 'failed'
&& isset($data['completed_at']);
}),
['id' => 4]
);
$this->repo->markFailed(4, 'Max retries exceeded');
}
public function testMarkFailedTruncatesErrorTo500Chars(): void
{
$this->mockDb->method('get')->willReturn([
'max_attempts' => 10,
'attempts' => 1,
]);
$longError = str_repeat('x', 600);
$this->mockDb->expects($this->once())
->method('update')
->with(
'pp_cron_jobs',
$this->callback(function ($data) {
return mb_strlen($data['last_error']) <= 500;
}),
['id' => 1]
);
$this->repo->markFailed(1, $longError);
}
// --- hasPendingJob ---
public function testHasPendingJobReturnsTrueWhenExists(): void
{
$this->mockDb->method('count')
->with('pp_cron_jobs', $this->callback(function ($where) {
return $where['job_type'] === 'apilo_sync_payment'
&& $where['status'] === ['pending', 'processing'];
}))
->willReturn(1);
$this->assertTrue($this->repo->hasPendingJob('apilo_sync_payment'));
}
public function testHasPendingJobReturnsFalseWhenNone(): void
{
$this->mockDb->method('count')->willReturn(0);
$this->assertFalse($this->repo->hasPendingJob('apilo_sync_payment'));
}
public function testHasPendingJobWithPayloadMatch(): void
{
$payload = ['order_id' => 42];
$this->mockDb->expects($this->once())
->method('count')
->with('pp_cron_jobs', $this->callback(function ($where) use ($payload) {
return $where['payload'] === json_encode($payload);
}))
->willReturn(1);
$this->assertTrue($this->repo->hasPendingJob('apilo_sync_payment', $payload));
}
// --- cleanup ---
public function testCleanupDeletesOldCompletedJobs(): void
{
$this->mockDb->expects($this->once())
->method('delete')
->with(
'pp_cron_jobs',
$this->callback(function ($where) {
return $where['status'] === ['completed', 'failed', 'cancelled']
&& isset($where['updated_at[<]']);
})
);
$this->repo->cleanup(30);
}
// --- recoverStuck ---
public function testRecoverStuckResetsProcessingJobs(): void
{
$this->mockDb->expects($this->once())
->method('update')
->with(
'pp_cron_jobs',
$this->callback(function ($data) {
return $data['status'] === 'pending'
&& $data['started_at'] === null;
}),
$this->callback(function ($where) {
return $where['status'] === 'processing'
&& isset($where['started_at[<]']);
})
);
$this->repo->recoverStuck(30);
}
// --- getDueSchedules ---
public function testGetDueSchedulesReturnsEnabledSchedules(): void
{
$schedules = [
['id' => 1, 'job_type' => 'price_history', 'interval_seconds' => 86400],
];
$this->mockDb->expects($this->once())
->method('select')
->with(
'pp_cron_schedules',
'*',
$this->callback(function ($where) {
return $where['enabled'] === 1
&& isset($where['OR']);
})
)
->willReturn($schedules);
$result = $this->repo->getDueSchedules();
$this->assertCount(1, $result);
}
// --- touchSchedule ---
public function testTouchScheduleUpdatesTimestamps(): void
{
$this->mockDb->expects($this->once())
->method('update')
->with(
'pp_cron_schedules',
$this->callback(function ($data) {
return isset($data['last_run_at'])
&& isset($data['next_run_at']);
}),
['id' => 5]
);
$this->repo->touchSchedule(5, 3600);
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace Tests\Unit\Domain\CronJob;
use Domain\CronJob\CronJobType;
use PHPUnit\Framework\TestCase;
class CronJobTypeTest extends TestCase
{
public function testAllTypesReturnsAllJobTypes(): void
{
$types = CronJobType::allTypes();
$this->assertContains('apilo_token_keepalive', $types);
$this->assertContains('apilo_send_order', $types);
$this->assertContains('apilo_sync_payment', $types);
$this->assertContains('apilo_sync_status', $types);
$this->assertContains('apilo_product_sync', $types);
$this->assertContains('apilo_pricelist_sync', $types);
$this->assertContains('apilo_status_poll', $types);
$this->assertContains('price_history', $types);
$this->assertContains('order_analysis', $types);
$this->assertContains('trustmate_invitation', $types);
$this->assertContains('google_xml_feed', $types);
$this->assertCount(11, $types);
}
public function testAllStatusesReturnsAllStatuses(): void
{
$statuses = CronJobType::allStatuses();
$this->assertContains('pending', $statuses);
$this->assertContains('processing', $statuses);
$this->assertContains('completed', $statuses);
$this->assertContains('failed', $statuses);
$this->assertContains('cancelled', $statuses);
$this->assertCount(5, $statuses);
}
public function testPriorityConstants(): void
{
$this->assertSame(10, CronJobType::PRIORITY_CRITICAL);
$this->assertSame(40, CronJobType::PRIORITY_SEND_ORDER);
$this->assertSame(50, CronJobType::PRIORITY_HIGH);
$this->assertSame(100, CronJobType::PRIORITY_NORMAL);
$this->assertSame(200, CronJobType::PRIORITY_LOW);
// Lower value = higher priority
$this->assertLessThan(CronJobType::PRIORITY_SEND_ORDER, CronJobType::PRIORITY_CRITICAL);
$this->assertLessThan(CronJobType::PRIORITY_HIGH, CronJobType::PRIORITY_SEND_ORDER);
$this->assertLessThan(CronJobType::PRIORITY_NORMAL, CronJobType::PRIORITY_HIGH);
$this->assertLessThan(CronJobType::PRIORITY_LOW, CronJobType::PRIORITY_NORMAL);
}
public function testCalculateBackoffExponential(): void
{
// Attempt 1: 60s
$this->assertSame(60, CronJobType::calculateBackoff(1));
// Attempt 2: 120s
$this->assertSame(120, CronJobType::calculateBackoff(2));
// Attempt 3: 240s
$this->assertSame(240, CronJobType::calculateBackoff(3));
// Attempt 4: 480s
$this->assertSame(480, CronJobType::calculateBackoff(4));
}
public function testCalculateBackoffCapsAtMax(): void
{
// Very high attempt should cap at MAX_BACKOFF_SECONDS (3600)
$this->assertSame(3600, CronJobType::calculateBackoff(10));
$this->assertSame(3600, CronJobType::calculateBackoff(20));
}
public function testJobTypeConstantsMatchStrings(): void
{
$this->assertSame('apilo_token_keepalive', CronJobType::APILO_TOKEN_KEEPALIVE);
$this->assertSame('apilo_send_order', CronJobType::APILO_SEND_ORDER);
$this->assertSame('apilo_sync_payment', CronJobType::APILO_SYNC_PAYMENT);
$this->assertSame('apilo_sync_status', CronJobType::APILO_SYNC_STATUS);
$this->assertSame('apilo_product_sync', CronJobType::APILO_PRODUCT_SYNC);
$this->assertSame('apilo_pricelist_sync', CronJobType::APILO_PRICELIST_SYNC);
$this->assertSame('apilo_status_poll', CronJobType::APILO_STATUS_POLL);
$this->assertSame('price_history', CronJobType::PRICE_HISTORY);
$this->assertSame('order_analysis', CronJobType::ORDER_ANALYSIS);
$this->assertSame('trustmate_invitation', CronJobType::TRUSTMATE_INVITATION);
$this->assertSame('google_xml_feed', CronJobType::GOOGLE_XML_FEED);
}
public function testStatusConstantsMatchStrings(): void
{
$this->assertSame('pending', CronJobType::STATUS_PENDING);
$this->assertSame('processing', CronJobType::STATUS_PROCESSING);
$this->assertSame('completed', CronJobType::STATUS_COMPLETED);
$this->assertSame('failed', CronJobType::STATUS_FAILED);
$this->assertSame('cancelled', CronJobType::STATUS_CANCELLED);
}
}

View File

@@ -7,6 +7,8 @@ use Domain\Order\OrderRepository;
use Domain\Product\ProductRepository;
use Domain\Settings\SettingsRepository;
use Domain\Transport\TransportRepository;
use Domain\CronJob\CronJobRepository;
use Domain\CronJob\CronJobType;
class OrderAdminServiceTest extends TestCase
{
@@ -229,108 +231,14 @@ class OrderAdminServiceTest extends TestCase
}
// =========================================================================
// processApiloSyncQueue — awaiting apilo_order_id
// queueApiloSync — DB-based via CronJobRepository
// =========================================================================
private function getQueuePath(): string
public function testConstructorAcceptsCronJobRepo(): void
{
// Musi odpowiadać ścieżce w OrderAdminService::apiloSyncQueuePath()
// dirname(autoload/Domain/Order/, 2) = autoload/
return dirname(__DIR__, 4) . '/autoload/temp/apilo-sync-queue.json';
}
private function writeQueue(array $queue): void
{
$path = $this->getQueuePath();
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
}
file_put_contents($path, json_encode($queue, JSON_PRETTY_PRINT));
}
private function readQueue(): array
{
$path = $this->getQueuePath();
if (!file_exists($path)) return [];
$content = file_get_contents($path);
return $content ? json_decode($content, true) : [];
}
protected function tearDown(): void
{
$path = $this->getQueuePath();
if (file_exists($path)) {
unlink($path);
}
parent::tearDown();
}
public function testProcessApiloSyncQueueKeepsTaskWhenApiloOrderIdIsNull(): void
{
// Zamówienie bez apilo_order_id — task powinien zostać w kolejce
$this->writeQueue([
'42' => [
'order_id' => 42,
'payment' => 1,
'status' => null,
'attempts' => 0,
'last_error' => 'awaiting_apilo_order',
'updated_at' => '2026-01-01 00:00:00',
],
]);
$orderRepo = $this->createMock(OrderRepository::class);
$orderRepo->method('findRawById')
->with(42)
->willReturn([
'id' => 42,
'apilo_order_id' => null,
'paid' => 1,
'summary' => '100.00',
]);
$service = new OrderAdminService($orderRepo);
$processed = $service->processApiloSyncQueue(10);
$this->assertSame(1, $processed);
$queue = $this->readQueue();
$this->assertArrayHasKey('42', $queue);
$this->assertSame('awaiting_apilo_order', $queue['42']['last_error']);
$this->assertSame(1, $queue['42']['attempts']);
}
public function testProcessApiloSyncQueueRemovesTaskAfterMaxAttempts(): void
{
// Task z 49 próbami — limit to 50, więc powinien zostać usunięty
$this->writeQueue([
'42' => [
'order_id' => 42,
'payment' => 1,
'status' => null,
'attempts' => 49,
'last_error' => 'awaiting_apilo_order',
'updated_at' => '2026-01-01 00:00:00',
],
]);
$orderRepo = $this->createMock(OrderRepository::class);
$orderRepo->method('findRawById')
->with(42)
->willReturn([
'id' => 42,
'apilo_order_id' => null,
'paid' => 1,
'summary' => '100.00',
]);
$service = new OrderAdminService($orderRepo);
$processed = $service->processApiloSyncQueue(10);
$this->assertSame(1, $processed);
$queue = $this->readQueue();
$this->assertArrayNotHasKey('42', $queue);
$cronJobRepo = $this->createMock(CronJobRepository::class);
$service = new OrderAdminService($orderRepo, null, null, null, $cronJobRepo);
$this->assertInstanceOf(OrderAdminService::class, $service);
}
}

View File

@@ -53,4 +53,15 @@ class ProductArchiveControllerTest extends TestCase
$this->assertCount(1, $params);
$this->assertEquals('Domain\Product\ProductRepository', $params[0]->getType()->getName());
}
public function testHasBulkDeletePermanentMethod(): void
{
$this->assertTrue(method_exists($this->controller, 'bulk_delete_permanent'));
}
public function testBulkDeletePermanentMethodReturnType(): void
{
$reflection = new \ReflectionClass($this->controller);
$this->assertEquals('void', (string)$reflection->getMethod('bulk_delete_permanent')->getReturnType());
}
}

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

Binary file not shown.

View File

@@ -0,0 +1,26 @@
{
"changelog": "FIX - refactor importu zdjec w shopPRO import (walidacja HTTP, curl timeouty, logi), FIX - saveCustomFields tylko przy jawnym podaniu, FIX - delete() czysci custom_fields, FIX - sciezka upload w API, NEW - trwale usuwanie produktow z archiwum",
"version": "0.323",
"files": {
"added": [
],
"deleted": [
],
"modified": [
"autoload/Domain/Integrations/IntegrationsRepository.php",
"autoload/Domain/Product/ProductRepository.php",
"autoload/admin/Controllers/ProductArchiveController.php",
"autoload/api/Controllers/ProductsApiController.php"
]
},
"checksum_zip": "sha256:8e779c9b2ad63bbfb478692d20a5327eeb9dc51f83d5da0ec6efe1a61917e8a5",
"sql": [
],
"date": "2026-02-24",
"directories_deleted": [
]
}

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

Binary file not shown.

View File

@@ -0,0 +1,7 @@
F: ../autoload/.DS_Store
F: ../autoload/front/.DS_Store
F: ../logs/2024-01-05-order-set-as-paid.txt
F: ../logs/2024-08-20-order-set-as-paid.txt
F: ../logs/apilo.txt
F: ../logs/logs-db-2024-08-31.log
F: ../templates/.DS_Store

View File

@@ -0,0 +1,37 @@
{
"changelog": "NEW - system kolejki zadań cron (DB), priorytetowe przetwarzanie, retry/backoff, ochrona endpointu cron",
"version": "0.324",
"files": {
"added": [
"autoload/Domain/CronJob/CronJobProcessor.php",
"autoload/Domain/CronJob/CronJobRepository.php",
"autoload/Domain/CronJob/CronJobType.php"
],
"deleted": [
"autoload/.DS_Store",
"autoload/front/.DS_Store",
"logs/2024-01-05-order-set-as-paid.txt",
"logs/2024-08-20-order-set-as-paid.txt",
"logs/apilo.txt",
"logs/logs-db-2024-08-31.log",
"templates/.DS_Store"
],
"modified": [
"autoload/Domain/Order/OrderAdminService.php",
"autoload/admin/App.php",
"autoload/api/ApiRouter.php",
"autoload/front/App.php",
"cron.php"
]
},
"checksum_zip": "sha256:63237e6bdb2ed2c2632cebb45431b26ecd5717b129aa2180540ebce0c3bff77f",
"sql": [
"CREATE TABLE IF NOT EXISTS 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, payload TEXT NULL, result TEXT NULL, 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",
"CREATE TABLE IF NOT EXISTS 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",
"INSERT INTO pp_cron_schedules (job_type, interval_seconds, priority, max_attempts) VALUES ('apilo_token_keepalive', 240, 10, 3), ('apilo_send_order', 60, 40, 10), ('apilo_product_sync', 600, 100, 3), ('apilo_pricelist_sync', 3600, 100, 3), ('apilo_status_poll', 600, 100, 3), ('price_history', 86400, 100, 3), ('order_analysis', 600, 100, 3), ('trustmate_invitation', 600, 200, 3), ('google_xml_feed', 3600, 200, 3)"
],
"date": "2026-02-27",
"directories_deleted": [
]
}

View File

@@ -0,0 +1,45 @@
-- System kolejki zadań cron
-- Wersja: 0.324
CREATE TABLE IF NOT EXISTS 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,
payload TEXT NULL,
result TEXT NULL,
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;
CREATE TABLE IF NOT EXISTS 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;
-- Harmonogramy zadań
INSERT INTO pp_cron_schedules (job_type, interval_seconds, priority, max_attempts) VALUES
('apilo_token_keepalive', 240, 10, 3),
('apilo_send_order', 60, 40, 10),
('apilo_product_sync', 600, 100, 3),
('apilo_pricelist_sync', 3600, 100, 3),
('apilo_status_poll', 600, 100, 3),
('price_history', 86400, 100, 3),
('order_analysis', 600, 100, 3),
('trustmate_invitation', 600, 200, 3),
('google_xml_feed', 3600, 200, 3);

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

Binary file not shown.

View File

@@ -0,0 +1,23 @@
{
"changelog": "FIX - naprawione krzaczki (mojibake) w changelog, limit wyswietlania do 5 wersji wstecz od wersji instancji",
"version": "0.325",
"files": {
"added": [
],
"deleted": [
],
"modified": [
"admin/templates/update/main-view.php"
]
},
"checksum_zip": "sha256:2fbafb77747d60e90d511f76d1c974bd866519a3ebb4d2a7a1dda062c00572f2",
"sql": [
],
"date": "2026-02-27",
"directories_deleted": [
]
}

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

Binary file not shown.

View File

@@ -0,0 +1,24 @@
{
"changelog": "NEW - Endpoint API categories/list: plaska lista aktywnych kategorii (id, parent_id, title) w domyslnym jezyku sklepu",
"version": "0.326",
"files": {
"added": [
"autoload/api/Controllers/CategoriesApiController.php"
],
"deleted": [
],
"modified": [
"autoload/Domain/Update/UpdateRepository.php",
"autoload/api/ApiRouter.php"
]
},
"checksum_zip": "sha256:982e3257562e9d6f96bde3aa817918401e7fb0db4900925b34b6adb5068703f1",
"sql": [
],
"date": "2026-02-27",
"directories_deleted": [
]
}

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

Binary file not shown.

View File

@@ -0,0 +1,25 @@
{
"changelog": "NEW - masowe usuwanie produktow w archiwum",
"version": "0.327",
"files": {
"added": [
],
"deleted": [
],
"modified": [
"admin/templates/product-archive/products-list-custom-script.php",
"admin/templates/product-archive/products-list.php",
"autoload/admin/Controllers/ProductArchiveController.php"
]
},
"checksum_zip": "sha256:02e43a9b4bf13eab08d739cf710dcdc37ee242c62e991427980dc0f88e60e96a",
"sql": [
],
"date": "2026-02-27",
"directories_deleted": [
]
}

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

Binary file not shown.

View File

@@ -0,0 +1,23 @@
{
"changelog": "NEW - ikona kopiowania wartosci atrybutow w szczegolach zamowienia",
"version": "0.328",
"files": {
"added": [
],
"deleted": [
],
"modified": [
"admin/templates/shop-order/order-details-custom-script.php"
]
},
"checksum_zip": "sha256:db8ddf18bc08ab7d194fbb8a9949f2366185f13cbfd64bec4ad7c600736ded20",
"sql": [
],
"date": "2026-02-27",
"directories_deleted": [
]
}

834
updates/changelog-data.html Normal file
View File

@@ -0,0 +1,834 @@
<b>ver. 0.328 - 27.02.2026</b><br />
NEW - ikona kopiowania wartosci atrybutow w szczegolach zamowienia
<hr>
<b>ver. 0.327 - 27.02.2026</b><br />
NEW - masowe usuwanie produktow w archiwum
<hr>
<b>ver. 0.326 - 27.02.2026</b><br />
NEW - Endpoint API categories/list: plaska lista aktywnych kategorii (id, parent_id, title) w domyslnym jezyku sklepu
<hr>
<b>ver. 0.326 - 27.02.2026</b><br />
NEW - Endpoint API categories/list: plaska lista aktywnych kategorii (id, parent_id, title) w domyslnym jezyku sklepu
<hr>
<b>ver. 0.326 - 27.02.2026</b><br />
NEW - Endpoint API categories/list: plaska lista aktywnych kategorii (id, parent_id, title) w domyslnym jezyku sklepu
<hr>
<b>ver. 0.325 - 27.02.2026</b><br />
FIX - naprawione krzaczki (mojibake) w changelog, limit wyswietlania do 5 wersji wstecz od wersji instancji
<hr>
<b>ver. 0.324 - 27.02.2026</b><br />
NEW - system kolejki zadaÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä„Äľ cron (DB), priorytetowe przetwarzanie, retry/backoff, ochrona endpointu cron
<hr>
<b>ver. 0.323 - 24.02.2026</b><br />
FIX - refactor importu zdjec w shopPRO import (walidacja HTTP, curl timeouty, logi), FIX - saveCustomFields tylko przy jawnym podaniu, FIX - delete() czysci custom_fields, FIX - sciezka upload w API, NEW - trwale usuwanie produktow z archiwum
<hr>
<b>ver. 0.322 - 24.02.2026</b><br />
FIX - custom_fields: jawne mapowanie kluczy w ProductRepository, spÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡jne !empty w ProductsApiController
<hr>
<b>ver. 0.321 - 24.02.2026</b><br />
NEW - API: obsÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡uga custom_fields w create/update produktu
<hr>
<b>ver. 0.320 - 24.02.2026</b><br />
NEW - API: endpoint ensure_producer (znajdÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä‚ââ¬ĹľÄ„ââ¬Â¦Ä„ąĹź lub utwÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡rz producenta); GET product zwraca producer_name
<hr>
<b>ver. 0.319 - 24.02.2026</b><br />
FIX - usuniÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ‚ââ¬Ä…Ä‚Ă˜cie shopPRO eksportu produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w; API produktu: dodano custom_fields i security_information
<hr>
<b>ver. 0.318 - 24.02.2026</b><br />
NEW - shopPRO export produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w + API endpoints (ensure_attribute, ensure_attribute_value, upload_image)
<hr>
<b>ver. 0.317 - 23.02.2026</b><br />
FIX - klucz API: fix zapisu (brakowalo w whiteliÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşcie), przycisk Generuj losowy klucz, ulepszony routing API
<hr>
<b>ver. 0.316 - 23.02.2026</b><br />
FIX - migracja brakujacej kolumny type w pp_shop_products_custom_fields
<hr>
<b>ver. 0.315 - 23.02.2026</b><br />
FIX - PDOException w listowaniu atrybutow admin (SQLSTATE HY093)
<hr>
<b>ver. 0.314 - 23.02.2026</b><br />
FIX - naprawa globalnej wyszukiwarki admin (Content-Type, Cache-Control, POST, try/catch), NEW - title strony z numerem zamÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡wienia
<hr>
<b>ver. 0.313 - 23.02.2026</b><br />
FIX - sync pÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡atnoÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşci Apilo (int cast na apilo_order_id PPxxxxxx dawaÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡ 0) + logowanie decyzji sync do pp_log
<hr>
<b>ver. 0.312 - 23.02.2026</b><br />
FIX - krytyczne bugi integracji Apilo: curl_getinfo po curl_close, nieskoÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä„Äľczona pÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ‚ââ¬Ä…Ä‚Ă˜tla wysyÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡ki, ceny 0.00 PLN, walidacja cen
<hr>
<b>ver. 0.311 - 23.02.2026</b><br />
FIX - race condition callback pÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡atnoÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşci Apilo, persistence filtrÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w tabel admin, poprawki cen zamÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡wieÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä„Äľ
<hr>
<b>ver. 0.310 - 23.02.2026</b><br />
NEW - Zakladka Logi w sekcji Integracje (podglad pp_log z paginacja, sortowaniem, filtrami)
<hr>
<b>ver. 0.309 - 23.02.2026</b><br />
NEW - ApiloLogger (logowanie operacji Apilo do pp_log), cache-busting CSS/JS w admin panelu, poprawki UI listy produktow, clipboard API
<hr>
<b>ver. 0.308 - 22.02.2026</b><br />
NEW - kolorowe badge statusow zamowien, walidacja hex, sanityzacja HTML transport, optymalizacja SQL
<hr>
<b>ver. 0.307 - 22.02.2026</b><br />
NEW - przycisk Sprawdz aktualizacje w panelu admina, NEW - auto-generowany changelog z manifestow
<hr>
<b>ver. 0.306 - 22.02.2026</b><br />
FIX - ukrywanie form dostawy gdy nie ma dostepnych form platnosci
<hr>
<b>ver. 0.305 - 22.02.2026</b><br />
FIX - naprawa kolejnosci atrybutow permutacji, NEW - pasek postepu darmowej dostawy w koszyku
<hr>
<b>ver. 0.304 - 22.02.2026</b><br />
NEW - konfigurowalne limity kwotowe metod platnosci (min/max kwota zamowienia)
<hr>
<b>ver. 0.303 - 22.02.2026</b><br />
FIX - naprawiono wyswietlanie atrybutow produktu na froncie (kolizja kolejnosci), NEW - przycisk Podglad w edycji produktu
<hr>
<b>ver. 0.302 - 22.02.2026</b><br />
NEW - REST API wariantow produktow (CRUD), slownik atrybutow, filtrowanie po atrybutach, wzbogacone atrybuty z tlumaczeniami
<hr>
<b>ver. 0.301 - 22.02.2026</b><br />
NEW - Ukrywalne filtry tabel, mobilna wersja szczegÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡Ä„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w zamÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡wienia
<hr>
<b>ver. 0.300 - 21.02.2026</b><br />
- NEW - System aktualizacji oparty na manifestach JSON (checksum SHA256, backup plikÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w, automatyczny build)
- NEW - Panel logu aktualizacji w panelu admina
<hr>
<b>ver. 0.299 - 21.02.2026</b><br />
- NEW - Ukrywanie/pokazywanie kolumn w tabelach admina (toggle switch + localStorage)
<hr>
<b>ver. 0.298 - 20.02.2026</b><br />
- FIX - kilka poprawek po aktualizacji
<hr>
<b>ver. 0.297 - 19.02.2026</b><br />
- NEW - REST API produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w (lista, szczegÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡y, tworzenie, aktualizacja)
- NEW - Endpoint products z filtrowaniem, sortowaniem i paginacjÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬ĹˇÄ‚æ
- NEW - Partial update produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w (tylko zmienione pola)
<hr>
<b>ver. 0.296 - 19.02.2026</b><br />
- NEW - REST API zamÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡wieÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä„Äľ dla ordersPRO (lista, szczegÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡y, zmiana statusu, pÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡atnoÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşci)
- NEW - Endpointy sÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡ownikowe (statusy, transporty, metody pÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡atnoÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşci)
- NEW - Autentykacja API przez X-Api-Key header
- NEW - Kolumna updated_at w pp_shop_orders (polling zmian)
<hr>
<b>ver. 0.295 - 19.02.2026</b><br />
- NEW - Edycja produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w w zamÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡wieniu z panelu admina (dodawanie, usuwanie, zmiana iloÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşci/cen)
- NEW - Wyszukiwarka produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w AJAX w formularzu edycji zamÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡wienia
- NEW - Automatyczna korekta stanÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w magazynowych i przeliczanie kosztu dostawy
- FIX - Cena promo w zamÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡wieniu = 0 gdy identyczna z cenÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬ĹˇÄ‚æ bazowÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬ĹˇÄ‚æ
<hr>
<b>ver. 0.294 - 19.02.2026</b><br />
- FIX - Code review zakoÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä„Äľczony (96/96 klas, ~1144 metod): 27 fixÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w across all layers
- FIX - Domain: null guard na query()->fetchAll() w 8 repozytoriach, redundancja DI w PromotionRepository
- FIX - Admin: null safety find() ?: [] w 10 kontrolerach, null guard w App logowaniu/2FA
- FIX - Front: LayoutEngine undefined $level + $_GET null check, ShopBasketController missing global $lang_id
- FIX - Shared: Helpers $_GET null check + bug 'png' → 'image/png' (Imagick lossless WebP nigdy nie dziaÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡aÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡)
<hr>
<b>ver. 0.293 - 19.02.2026</b><br />
- FIX - ArticleRepository: SQL injection fix (addslashesÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚ââ¬Ä…Ä‚Ă˜Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬ĹˇÄ‚Ă Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚Ă˜Ä˘â‚¬ĹľÄ‹Ă˜parameterized), uproszczenie articleDetailsFrontend
- FIX - AttributeRepository: martwy class_exists('\S') blokowal czyszczenie cache/temp
- FIX - CategoryRepository: martwy class_exists('\S') blokowal generowanie linkow SEO kategorii
- FIX - BannerRepository: parametryzacja dat w SQL + null guard na query()
- FIX - BasketCalculator: null guard checkProductQuantityInStock + opcjonalne DI params summaryPrice/calculateBasketProductPrice
- FIX - PromotionRepository: null guard na $basket (produkcyjny fatal error)
- UPDATE - OrderRepository, ShopBasketController, ajax.php: jawne DI zamiast globals w callerach BasketCalculator
<hr>
<b>ver. 0.292 - 18.02.2026</b><br />
- UPDATE - pelna migracja front\factory\ do Domain (5 ostatnich klas: ShopProduct, ShopPaymentMethod, ShopPromotion, ShopStatuses, ShopTransport)
- UPDATE - ProductRepository: ~20 nowych metod frontendowych (cache Redis, lazy loading, SKU/EAN fallback)
- UPDATE - PromotionRepository: 5 metod aplikowania promocji (applyTypeWholeBasket/CheapestProduct/CategoriesOr/CategoriesAnd/CategoryCondition)
- UPDATE - TransportRepository: 4 metody frontendowe z cache (transportMethodsFront, transportCostCached, findActiveByIdCached, forPaymentMethod)
- UPDATE - PaymentMethodRepository: metody frontendowe z Redis cache
- CLEANUP - usuniety caly folder front\factory\ (20 klas zmigrowanych) + 4 inne klasy legacy
- FIX - broken transports_list() w ajax.php zastapiony nowa metoda forPaymentMethod()
- UPDATE - front\controls\Site przemianowany na front\App (router, camelCase: checkUrlParams, pageTitle)
- UPDATE - front\view\Site przemianowany na front\LayoutEngine (layout engine, camelCase: cookieInformation)
- CLEANUP - usuniete autoload/front/controls/ i autoload/front/view/ (puste foldery + pliki legacy)
- UPDATE - usuniecie 12 legacy klas z autoload/shop/ (~2363 linii) — pelna migracja na Domain-Driven Architecture
- UPDATE - class.Order.php: logika Apilo sync i email statusu przeniesiona do OrderAdminService
- UPDATE - class.Product.php: ~20 metod przeniesionych do ProductRepository, calculate_basket_product_price do BasketCalculator
- FIX - findCached(): stale Redis cache z obiektami \shop\Product powodowal ceny 0,00 zl
- FIX - szablony: konwersja object access na array access po migracji Product
- UPDATE - AttributeRepository::getAttributeValueById() — dodano Redis cache
- CLEANUP - katalog autoload/shop/ pusty, zero referencji \shop\ w aktywnym kodzie
<hr>
<b>ver. 0.291 - 17.02.2026</b><br />
- UPDATE - migracja front\controls\ShopProducer + shop\Producer do Domain\Producer\ProducerRepository + front\Controllers\ShopProducerController
- FIX - bug shop\Producer::__get() referowal nieistniejace $this->data
<hr>
<b>ver. 0.290 - 17.02.2026</b><br />
- UPDATE - migracja front\factory\ShopCoupon + front\controls\ShopCoupon do Domain\Coupon\CouponRepository + front\Controllers\ShopCouponController
- UPDATE - migracja front\factory\ShopOrder + front\controls\ShopOrder + front\view\ShopOrder do Domain\Order\OrderRepository + front\Controllers\ShopOrderController
- FIX - kupony jednorazowe nigdy nie byly oznaczane jako uzyte (is_one_time/set_as_used w shop\Coupon)
- FIX - webhooks przelewy24/hotpay ujednolicone z tpay (poprawna obsluga Apilo sync)
<hr>
<b>ver. 0.289 - 17.02.2026</b><br />
- UPDATE - migracja front\factory\ShopCategory + front\view\ShopCategory do Domain\Category\CategoryRepository + front\Views\ShopCategory
- UPDATE - migracja front\factory\ShopClient + front\view\ShopClient + front\controls\ShopClient do Domain\Client\ClientRepository + front\Views\ShopClient + front\Controllers\ShopClientController
- FIX - usuniety hardcoded password bypass 'Legia1916' w logowaniu klienta
<hr>
<b>ver. 0.288 - 17.02.2026</b><br />
- UPDATE - migracja front\factory\ShopBasket do Domain\Basket\BasketCalculator (4 metody statyczne)
- UPDATE - migracja front\controls\ShopBasket do front\Controllers\ShopBasketController (camelCase, instancyjny)
- UPDATE - routing snake_case->camelCase w dispatch dla nowych kontrolerow
- CLEANUP - usunieta klasa cms\Layout (zastapiona $layoutsRepo->find)
- CLEANUP - usuniete 3 klasy legacy (front\factory\ShopBasket, front\controls\ShopBasket, cms\Layout)
<hr>
<b>ver. 0.287 - 17.02.2026</b><br />
- UPDATE - migracja front\factory\Scontainers do Domain\Scontainers\ScontainersRepository (frontScontainerDetails z Redis cache)
- UPDATE - migracja front\factory\ShopAttribute do Domain\Attribute\AttributeRepository (frontAttributeDetails, frontValueDetails z Redis cache)
- CLEANUP - usuniete 3 klasy legacy (front\factory\Scontainers, front\view\Scontainers, front\factory\ShopAttribute)
<hr>
<b>ver. 0.286 - 17.02.2026</b><br />
- UPDATE - migracja front\factory\Layouts do Domain\Layouts\LayoutsRepository (6 metod frontend z Redis cache)
- UPDATE - migracja front\factory\Menu + front\factory\Pages do Domain\Pages\PagesRepository (6 metod frontend z Redis cache)
- UPDATE - migracja front\view\Menu do front\Views\Menu (nowy namespace)
- CLEANUP - usuniete 4 klasy legacy + 1 martwy szablon (submenu.php)
- FIX - null $lang_id przy wczesnym wywolaniu check_url_params()
<hr>
<b>ver. 0.285 - 17.02.2026</b><br />
- UPDATE - migracja class.Tpl.php do Shared\Tpl\Tpl (~135 plikow przepietych)
- CLEANUP - usunieta nieuzywana klasa CurlServer (curl.class.php)
- FIX - thumb.php: naprawa require po migracji Image do Shared namespace
- FIX - Tpl::render() branch 3: sprawdzal templates_user ale ladowal templates
<hr>
<b>ver. 0.284 - 16.02.2026</b><br />
- CLEANUP - usunieta klasa DbModel (base ORM) — logika wbudowana bezposrednio w shop\Promotion
<hr>
<b>ver. 0.283 - 16.02.2026</b><br />
- UPDATE - migracja class.S.php do Shared\Helpers\Helpers (~140 plikow przepietych)
- UPDATE - migracja class.Html.php do Shared\Html\Html
- UPDATE - migracja class.Email.php do Shared\Email\Email
- UPDATE - migracja class.Image.php do Shared\Image\ImageManipulator
- UPDATE - migracja class.Log.php do Shared\Log\Log (usunieta — logika przeniesiona)
- CLEANUP - usunieta class.Mobile_Detect.php (przestarzala detekcja UA)
- CLEANUP - usunieto 12 nieuzywanych metod z klasy S
- FIX - array_cartesian_product() — blad iteracji po niezdefiniowanej zmiennej
<hr>
<b>ver. 0.282 - 16.02.2026</b><br />
- UPDATE - Cache cleanup: eliminacja legacy class.Cache.php, migracja CacheHandler i RedisConnection do Shared\Cache namespace
- UPDATE - 60 odwolan CacheHandler i 12 odwolan RedisConnection przepietych na Shared\Cache\
- UPDATE - 13 metod front\factory przepietych z \Cache::fetch/store na CacheHandler (ShopProduct, ShopPaymentMethod, ShopCategory, ShopTransport, ShopAttribute)
- FIX - naprawione rozbieznosci kluczy cache (random_products, category_name)
- CLEANUP - usuniete: class.Cache.php, class.CacheHandler.php, class.RedisConnection.php
- UPDATE - testy: OK (454 tests, 1449 assertions)
<hr>
<b>ver. 0.281 - 16.02.2026</b><br />
- UPDATE - migracja Banners frontend: factory + view do Domain/Views (DI)
- NEW - `front\Views\Banners` — czysty VIEW (banners, mainBanner)
- UPDATE - `BannerRepository` rozszerzony o 2 metody frontendowe (banners, mainBanner) z Redis cache
- UPDATE - `front\view\Site::show()` przepiety na repo + Views
- CLEANUP - usuniete: front\factory\Banners, front\view\Banners
- UPDATE - testy: OK (454 tests, 1449 assertions)
<hr>
<b>ver. 0.280 - 16.02.2026</b><br />
- UPDATE - migracja Articles frontend: factory + view + encja do Domain/Views (DI)
- NEW - `front\Views\Articles` — czysty VIEW + utility (renderowanie, generateTableOfContents, generateHeadersIds, getImage)
- UPDATE - `ArticleRepository` rozszerzony o 8 metod frontendowych (z Redis cache)
- UPDATE - `front\view\Site::show()` — 5 sekcji przepietych na repo + Views
- UPDATE - `front\controls\Site::route()` — single article + page_type switch przepiete na repo + Views
- UPDATE - 5 szablonow `templates/articles/*` przepietych na `\front\Views\Articles::`
- CLEANUP - usuniete: `class.Article` (encja + metody statyczne), `front\factory\Settings` (fasada)
- FIX - eliminacja `global $lang` z `articleNoindex()`, eliminacja zaleznosci od `front\factory\Pages::page_sort()`
- UPDATE - testy: `OK (450 tests, 1431 assertions)`
<hr>
<b>ver. 0.279 - 16.02.2026</b><br />
- UPDATE - migracja Newsletter frontend: factory + view + controls do Domain/Controllers/Views (DI)
- UPDATE - nowy namespace `front\Controllers` z `NewsletterController` (DI via factory closures)
- UPDATE - nowy namespace `front\Views` z `Languages` i `Newsletter` (czyste VIEW, statyczne metody)
- UPDATE - routing frontend: `Site::getControllerFactories()` z fallback na stare kontrolery
- FIX - `newsletter_unsubscribe()` — poprawiona skladnia medoo `delete()` (2 argumenty zamiast 3)
- UPDATE - eliminacja fasady `front\factory\Languages` — 26 zaleznosci przepietych na `LanguagesRepository`
- CLEANUP - usuniete: `front\factory\Languages`, `front\factory\Newsletter`, `front\view\Languages`, `front\view\Newsletter`, `front\controls\Newsletter`
- UPDATE - testy: `OK (437 tests, 1398 assertions)`
<hr>
<b>ver. 0.278 - 16.02.2026</b><br />
- UPDATE - migracja Settings + Languages do wspolnych klas Domain (z cache Redis)
- FIX - `get_single_settings_value()` — parametr `$param` poprawnie uzywany (wczesniej hardcoded `firm_name`)
<hr>
<b>ver. 0.277 - 16.02.2026</b><br />
- NEW - migracja modulu `ShopProduct` (factory) — pelna migracja ~40 metod do `ProductRepository` + ~30 akcji w `ShopProductController`
- NEW - migracja modulu `Dashboard` do Domain + DI (`DashboardRepository`, `DashboardController`)
- NEW - migracja modulu `Update` do Domain + DI (`UpdateRepository`, `UpdateController`)
- UPDATE - klasa `admin\Site` przemianowana na `admin\App` (plik `App.php`)
- UPDATE - refaktoring routingu — usunieto fallback na stare kontrolery, uproszczony routing
- UPDATE - template `update/main-view.php` — panele zamiast `gridEdit`, `$.confirm()` zamiast `$.prompt()`
- CLEANUP - usuniete stare foldery: `autoload/admin/controls/`, `autoload/admin/factory/`, `autoload/admin/view/`
- CLEANUP - usuniete legacy: `class.Dashboard.php` (controls/shop), `class.Update.php` (controls/factory/view), `class.Articles.php` (factory), `class.Page.php` (view), `class.ShopProduct.php` (controls/factory/view)
- UPDATE - `front\factory\Newsletter` przepieta na `ArticleRepository::articlesByDateAdd()`
- UPDATE - testy: `OK (414 tests, 1335 assertions)`
<hr>
<b>ver. 0.276 - 15.02.2026</b><br />
- NEW - migracja modulu `ShopOrder` do architektury Domain + DI (`Domain\Order\OrderRepository`, `Domain\Order\OrderAdminService`, `admin\Controllers\ShopOrderController`)
- UPDATE - modul `/admin/shop_order/*` przepiety na nowy routing (kanoniczny URL `/admin/shop_order/list/`) i nowe widoki (`orders-list`, `order-details`, `order-edit`)
- FIX - stabilizacja listy zamowien (`OrderRepository::listForAdmin`) oraz poprawa wygladu tabeli (`components/table-list`, wyrownanie komorek i `text-right`)
- CLEANUP - usuniete legacy klasy/pliki: `autoload/admin/controls/class.ShopOrder.php`, `autoload/admin/factory/class.ShopOrder.php`, `admin/templates/shop-order/view-list.php`
- UPDATE - usunieta fasada `autoload/admin/factory/class.Integrations.php`; wywolania przepiete na `Domain\Integrations\IntegrationsRepository`
- NEW - globalna wyszukiwarka admin (produkty + zamowienia) przy "Wyczysc cache" + endpoint `/admin/settings/globalSearchAjax/`
- FIX - wyszukiwanie po pelnym imieniu i nazwisku w global search
- UPDATE - testy: `OK (385 tests, 1246 assertions)`
- UPDATE - pliki aktualizacji: `updates/0.20/ver_0.276.zip`, `ver_0.276_files.txt`
<hr>
<b>ver. 0.275 - 15.02.2026</b><br />
- NEW - migracja modulu `ShopCategory` do architektury Domain + DI (`Domain\Category\CategoryRepository`, `admin\Controllers\ShopCategoryController`)
- UPDATE - modul `/admin/shop_category/*` przepiety na nowy routing (kanoniczny URL `/admin/shop_category/list/`) i endpointy AJAX kontrolera (`save_categories_order`, `save_products_order`, `cookie_categories`)
- UPDATE - widoki `shop-category/*`: wydzielenie skryptow do `*-custom-script.php`, ujednolicone strzalki drzewa (`button + caret + aria-expanded`)
- UPDATE - przepiecie zaleznosci `ShopProduct` z `admin\factory\ShopCategory` na `Domain\Category\CategoryRepository`
- CLEANUP - usuniete legacy klasy/pliki: `autoload/admin/controls/class.ShopCategory.php`, `autoload/admin/factory/class.ShopCategory.php`, `autoload/admin/view/class.ShopCategory.php`
- UPDATE - testy: `OK (377 tests, 1197 assertions)`
- UPDATE - pliki aktualizacji: `updates/0.20/ver_0.275.zip`, `ver_0.275_files.txt`
<hr>
<b>ver. 0.274 - 15.02.2026</b><br />
- NEW - migracja modulu `ShopClients` do architektury Domain + DI (`Domain\Client\ClientRepository`, `admin\Controllers\ShopClientsController`)
- UPDATE - modul `/admin/shop_clients/*` przepiety na `components/table-list` (lista klientow i szczegoly zamowien)
- UPDATE - routing i menu admin przepiete na kanoniczny URL `/admin/shop_clients/list/`
- CLEANUP - usuniete legacy klasy/pliki: `autoload/admin/controls/class.ShopClients.php`, `autoload/admin/factory/class.ShopClients.php`
- UPDATE - testy: `OK (361 tests, 1125 assertions)`
- UPDATE - pliki aktualizacji: `updates/0.20/ver_0.274.zip`, `ver_0.274_files.txt`
<hr>
<b>ver. 0.273 - 15.02.2026</b><br />
- NEW - migracja `/admin/shop_product/mass_edit/*` do `Domain\Product\ProductRepository` + `admin\Controllers\ShopProductController` (DI + routing)
- UPDATE - nowy widok/skrypt masowej edycji (`mass-edit`, `mass-edit-custom-script`) z iCheck i ujednoliconymi strzalkami drzewa
- FIX - zaznaczanie kategorii w mass-edit nie zaznacza automatycznie produktow na liscie
- UPDATE - ujednolicenie strzalek/checkboxow w drzewkach: `/admin/pages/list/*` oraz zakladka wyswietlania w `/admin/articles/edit/*`
- UPDATE - testy: `OK (351 tests, 1091 assertions)`
- UPDATE - pliki aktualizacji: `updates/0.20/ver_0.273.zip`, `ver_0.273_files.txt`
<hr>
<b>ver. 0.272 - 15.02.2026</b><br />
- NEW - migracja modulu `ShopProductSets` do architektury Domain + DI (`Domain\ProductSet\ProductSetRepository`, `admin\Controllers\ShopProductSetsController`)
- UPDATE - modul `/admin/shop_product_sets/*` przepiety z legacy `grid/gridEdit` na `components/table-list` i `components/form-edit` + multi-select Selectize
- UPDATE - routing i menu admin przepiete na kanoniczny URL `/admin/shop_product_sets/list/`
- UPDATE - `shop\ProductSet` przepiety na fasade do `Domain\ProductSet\ProductSetRepository`
- CLEANUP - usuniete legacy klasy/pliki: `autoload/admin/controls/class.ShopProductSets.php`, `autoload/admin/factory/class.ShopProductSet.php`, `admin/templates/shop-product-sets/view-list.php`, `admin/templates/shop-product-sets/set-edit.php`
- UPDATE - testy: `OK (324 tests, 1000 assertions)` + nowe pliki testowe `ProductSetRepositoryTest`, `ShopProductSetsControllerTest`
- UPDATE - pliki aktualizacji: `updates/0.20/ver_0.272.zip`, `ver_0.272_files.txt`
<hr>
<b>ver. 0.271 - 14.02.2026</b><br />
- NEW - migracja modulu `ShopAttribute` do architektury Domain + DI (`Domain\Attribute\AttributeRepository`, `admin\Controllers\ShopAttributeController`)
- UPDATE - modul `/admin/shop_attribute/*` przepiety z legacy `grid/gridEdit` na `components/table-list`, `components/form-edit` oraz nowy edytor wartosci (`values-edit`)
- UPDATE - routing i menu admin przepiete na kanoniczny URL `/admin/shop_attribute/list/` (bez aliasow legacy)
- UPDATE - przepiecie zaleznosci kombinacji produktu: `admin\controls\ShopProduct`, `admin\factory\ShopProduct`, `admin/templates/shop-product/product-combination.php`
- CLEANUP - usuniete legacy klasy/pliki: `autoload/admin/controls/class.ShopAttribute.php`, `autoload/admin/factory/class.ShopAttribute.php`, `autoload/admin/view/class.ShopAttribute.php`, `admin/templates/shop-attribute/_partials/value.php`
- UPDATE - testy: `OK (312 tests, 948 assertions)` + nowe pliki testowe `AttributeRepositoryTest`, `ShopAttributeControllerTest`
- UPDATE - pliki aktualizacji: `updates/0.20/ver_0.271.zip`, `ver_0.271_files.txt`
<hr>
<b>ver. 0.270 - 14.02.2026</b><br />
- FIX - Apilo: `shop\Order::set_as_paid()` wysyla mapowany typ platnosci Apilo (z `payment_method_id`), zamiast stalego `type = 1`
- NEW - Apilo: dodana kolejka retry `temp/apilo-sync-queue.json` dla nieudanych syncow platnosci/statusu (chwilowa niedostepnosc API)
- UPDATE - `cron.php`: automatyczne ponawianie zaleglych syncow przez `Order::process_apilo_sync_queue(10)`
- UPDATE - debug Apilo: rozszerzone logi odpowiedzi o HTTP code i bledy cURL dla sync platnosci/statusu
- UPDATE - testy: `OK (300 tests, 895 assertions)`
- UPDATE - pliki aktualizacji: `updates/0.20/ver_0.270.zip`, `ver_0.270_files.txt`
<hr>
<b>ver. 0.269 - 14.02.2026</b><br />
- NEW - migracja modulu `ShopPaymentMethod` do architektury Domain + DI (`Domain\PaymentMethod\PaymentMethodRepository`, `admin\Controllers\ShopPaymentMethodController`)
- UPDATE - modul `/admin/shop_payment_method/*` przepiety z legacy `grid/gridEdit` na `components/table-list` i `components/form-edit` (nowe widoki listy i edycji)
- UPDATE - przepiecie zaleznosci na nowe repozytorium: `admin\controls\ShopTransport`, `front\factory\ShopPaymentMethod`, `shop\PaymentMethod`
- CLEANUP - usuniete legacy klasy/pliki: `autoload/admin/controls/class.ShopPaymentMethod.php`, `autoload/admin/factory/class.ShopPaymentMethod.php`, `autoload/admin/view/class.ShopPaymentMethod.php`, `admin/templates/shop-payment-method/view-list.php`
- UPDATE - Apilo: dodane automatyczne odswiezanie tokenu przed wygasnieciem (`apiloKeepalive`) oraz bardziej szczegolowe komunikaty bledow integracji
- UPDATE - testy: `OK (280 tests, 828 assertions)` + nowe pliki testowe `PaymentMethodRepositoryTest`, `ShopPaymentMethodControllerTest`
- UPDATE - pliki aktualizacji: `updates/0.20/ver_0.269.zip`, `ver_0.269_files.txt`
<hr>
<b>ver. 0.268 - 14.02.2026</b><br />
- NEW - migracja modulu `ShopStatuses` do architektury Domain + DI (`Domain\ShopStatus\ShopStatusRepository`, `admin\Controllers\ShopStatusesController`)
- UPDATE - modul `/admin/shop_statuses/*` przepiety z legacy `grid/gridEdit` na `components/table-list` i `components/form-edit`
- NEW - nowy typ pola formularza `color` (HTML5 color picker + pole tekstowe zsynchronizowane)
- UPDATE - `front\factory\ShopStatuses` dziala jako fasada do `Domain\ShopStatus\ShopStatusRepository`
- UPDATE - menu admin przepiete na kanoniczny URL `/admin/shop_statuses/list/`
- CLEANUP - usuniete legacy klasy: `autoload/admin/controls/class.ShopStatuses.php`, `autoload/admin/factory/class.ShopStatuses.php`
- UPDATE - reorganizacja dokumentacji technicznej: pliki przeniesione do folderu `docs/` i rozbite na mniejsze pliki tematyczne
- UPDATE - testy: `OK (254 tests, 736 assertions)` + nowe pliki testowe `ShopStatusRepositoryTest`, `ShopStatusesControllerTest`
- UPDATE - pliki aktualizacji: `updates/0.20/ver_0.268.zip`, `ver_0.268_files.txt`
<hr>
<b>ver. 0.267 - 13.02.2026</b><br />
- FIX - front: poprawione dobieranie layoutu dla kategorii/produktu/koszyka i innych stron moduÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡owych (fallback do layoutu domyÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşlnego)
- FIX - produkt/koszyk: poprawiona obsÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡uga iloÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşci dla kombinacji (stan 0 po dodaniu do koszyka, limit max, odczyt `stock_0_buy`)
- FIX - produkt: usuniÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ‚ââ¬Ä…Ä‚Ă˜ty bÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡Ă„‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬ĹˇÄ‚æd JS `TypeError: $(...).visible is not a function` (zamiana na `:visible`)
- FIX - SEO redirecty produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w: blokada konfliktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w po kopiowaniu URL oraz utwardzone wykrywanie pÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ‚ââ¬Ä…Ä‚Ă˜tli redirectÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w (`lang_id` + graf przejÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşĂââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬Ä…Ģ€Ç)
- UPDATE - admin: `input-switch` zapisuje wartoÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşĂââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬Ä…Ģ€Ç `on` (spÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡jnie z obsÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡ugÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬ĹˇÄ‚æ pÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡l checkbox w formularzach)
- CLEANUP - usuniÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ‚ââ¬Ä…Ä‚Ă˜te pliki: `apilo-bck`, `geocode-cache.php`
- UPDATE - testy: `OK (235 tests, 682 assertions)`
- UPDATE - pliki aktualizacji: `updates/0.20/ver_0.267.zip`, `ver_0.267_files.txt`, `ver_0.267_sql.txt`
<hr>
<b>ver. 0.266 - 13.02.2026</b><br />
- NEW - migracja modulu `ShopCoupon` do architektury Domain + DI (`Domain\Coupon\CouponRepository`, `admin\Controllers\ShopCouponController`)
- UPDATE - modul `/admin/shop_coupon/*` przepiety z legacy `grid/gridEdit` na `components/table-list` i `components/form-edit`
- UPDATE - nowe widoki i partiale: `shop-coupon/coupons-list`, `shop-coupon/coupon-edit-new`, `shop-coupon/coupon-categories-selector`, `shop-coupon/coupon-categories-tree`, `shop-coupon/coupon-edit-custom-script`
- UPDATE - zachowana kompatybilnosc aliasow legacy akcji (`view_list`, `coupon_edit`, `coupon_save`, `coupon_delete`) w nowym kontrolerze
- CLEANUP - usuniete legacy klasy/pliki: `autoload/admin/controls/class.ShopCoupon.php`, `autoload/admin/factory/class.ShopCoupon.php`, `admin/templates/shop-coupon/view-list.php`, `admin/templates/shop-coupon/coupon-edit.php`
- UPDATE - menu admin wskazuje kanoniczny URL `/admin/shop_coupon/list/`
- FIX - ujednolicone drzewka (strzalki + focus) i wyglad checkboxow miedzy `/admin/shop_coupon/edit/*` oraz `/admin/layouts/edit/*`
- UPDATE - testy: `OK (235 tests, 682 assertions)` + nowe pliki testowe `CouponRepositoryTest`, `ShopCouponControllerTest`
- UPDATE - pliki aktualizacji: `updates/0.20/ver_0.266.zip`, `ver_0.266_files.txt`
<hr>
<b>ver. 0.265 - 13.02.2026</b><br />
- UPDATE - modul `/admin/shop_promotion/*`: dodano pole `Data od` (`date_from`) w repozytorium, formularzu i liscie
- UPDATE - front: `shop\Promotion::get_active_promotions()` uwzglednia `date_from` (okno aktywnosci od-do)
- FIX - edycja promocji zapisuje aktualizacje rekordu zamiast tworzenia nowego (`id` przekazywane przez hidden field + fallback z URL)
- UPDATE - testy: `OK (222 tests, 614 assertions)`
<hr>
<b>ver. 0.264 - 13.02.2026</b><br />
- NEW - migracja modulu `ShopPromotion` do architektury Domain + DI (`Domain\Promotion\PromotionRepository`, `admin\Controllers\ShopPromotionController`)
- UPDATE - modul `/admin/shop_promotion/*` przepiety z legacy `grid/gridEdit` na `components/table-list` i `components/form-edit`
- UPDATE - nowe widoki i partiale: `shop-promotion/promotions-list`, `shop-promotion/promotion-edit`, `shop-promotion/promotion-categories-selector`, `shop-promotion/promotion-categories-tree`, `shop-promotion/promotion-edit-custom-script`
- CLEANUP - usuniete legacy klasy/pliki: `autoload/admin/controls/class.ShopPromotion.php`, `autoload/admin/factory/class.ShopPromotion.php`, `admin/templates/shop-promotion/view-list.php`
- UPDATE - menu admin wskazuje kanoniczny URL `/admin/shop_promotion/list/`
- UPDATE - testy: `OK (222 tests, 609 assertions)` + nowe pliki testowe `PromotionRepositoryTest`, `ShopPromotionControllerTest`
<hr>
<b>ver. 0.263 - 13.02.2026</b><br />
- NEW - migracja modulu `Integrations` do architektury Domain + DI (`Domain\Integrations\IntegrationsRepository`, `admin\Controllers\IntegrationsController`)
- CLEANUP - usunieto integracje Sellasist i Baselinker z calego projektu (kontrolery, factory, szablony, referencje w cron/Order/ShopStatuses/ShopTransport/ShopPaymentMethod/ShopProduct)
- UPDATE - `admin\factory\Integrations` jako fasada delegujaca do repozytorium (tylko Apilo + ShopPRO)
- FIX - naprawione polskie znaki w `product-edit.php` (usuniety podwojny encoding UTF-8/CP1250)
- CLEANUP - usuniete pliki: `controls/Integrations`, `controls/Baselinker`, `factory/Baselinker`, `front/factory/Shop`, `shop/ShopStatus`, szablony sellasist/baselinker
- UPDATE - pliki aktualizacji: `updates/0.20/ver_0.263.zip`, `ver_0.263_files.txt`
<hr>
<b>ver. 0.262 - 13.02.2026</b><br />
- NEW - migracja modulu `Pages` do architektury Domain + DI (`Domain\Pages\PagesRepository`, `admin\Controllers\PagesController`)
- UPDATE - widoki `/admin/pages/*` przepiete na nowy routing i komponent `components/form-edit` (menu/page edit)
- FIX - przywrocony przycisk generowania linku SEO w edycji strony (zakladka SEO, pola jezykowe)
- FIX - popup potwierdzenia usuwania menu/strony ujednolicony z `table-list-confirm-dialog` + poprawione polskie znaki
- CLEANUP - usuniete legacy pliki Pages: `admin/ajax/pages.php`, `autoload/admin/controls/class.Pages.php`, `autoload/admin/factory/class.Pages.php`, `autoload/admin/view/class.Pages.php`
- UPDATE - pliki aktualizacji: `updates/0.20/ver_0.262.zip`, `ver_0.262_files.txt`
<hr>
<b>ver. 0.261 - 13.02.2026</b><br />
- UPDATE - finalizacja refaktoryzacji modulu `Articles` (`/admin/articles`) w warstwie Domain + DI
- UPDATE - nowe akcje AJAX w `admin\Controllers\ArticlesController` (m.in. `files_order_save`)
- UPDATE - sortowanie zalacznikow i zdjec w edycji artykulu (drag&drop + zapis kolejnosci przy pierwszym zapisie)
- UPDATE - utwardzenie uploadow (wspolny helper `libraries/plupload/upload-common.php`, walidacje i tokeny)
- FIX - potwierdzenia usuwania zdjec/zalacznikow ujednolicone z widokiem listy (jquery-confirm)
- CLEANUP - usuniete legacy `admin/ajax/articles.php` i `autoload/admin/view/class.Articles.php`
- UPDATE - pliki aktualizacji: `updates/0.20/ver_0.261.zip`, `ver_0.261_files.txt`, `ver_0.261_sql.txt`
<hr>
<b>ver. 0.260 - 12.02.2026</b><br />
- NEW - migracja modulu `ArticlesArchive` do architektury Domain + DI (`admin\Controllers\ArticlesArchiveController`)
- UPDATE - `Domain\Article\ArticleRepository` rozszerzone o metody `listArchivedForAdmin`, `restore`, `deletePermanently`
- UPDATE - widok `/admin/articles_archive/view_list/` przepiety z legacy `grid` na `components/table-list`
- UPDATE - routing DI (`admin\Site`) rozszerzony o modul `ArticlesArchive` + mapowanie akcji `article_restore -> restore`
- CLEANUP - usuniete legacy klasy `autoload/admin/controls/class.ArticlesArchive.php`, `autoload/admin/factory/class.ArticlesArchive.php`, `autoload/admin/view/class.ArticlesArchive.php`
- UPDATE - plik do usuniecia dodany w `updates/0.20/ver_0.260_files.txt`
<hr>
<b>ver. 0.259 - 12.02.2026</b><br />
- NEW - migracja modulu `Scontainers` do architektury Domain + DI (`Domain\Scontainers\ScontainersRepository`, `admin\Controllers\ScontainersController`)
- UPDATE - widoki `/admin/scontainers/*` przepiete z legacy `grid/gridEdit` na `components/table-list` i `components/form-edit`
- UPDATE - routing DI (`admin\Site`) rozszerzony o modul `Scontainers` + mapowanie akcji `container_edit/container_save/container_delete`
- UPDATE - `admin\factory\Scontainers` dziala jako fasada do repozytorium (backward compatibility)
- UPDATE - `front\factory\Scontainers` korzysta z `Domain\Scontainers\ScontainersRepository`
- CLEANUP - usuniete legacy klasy `autoload/admin/controls/class.Scontainers.php`, `autoload/admin/view/class.Scontainers.php`
- UPDATE - plik do usuniecia dodany w `updates/0.20/ver_0.259_files.txt`
<hr>
<b>ver. 0.258 - 12.02.2026</b><br />
- UPDATE - modul `Newsletter`: funkcjonalnosc `Wysylka - przygotowanie` zostala tymczasowo wylaczona (menu + akcje `prepare/send/preview`)
- UPDATE - modul `Newsletter`: lista `Szablony uzytkownika` zostala tymczasowo wylaczona (menu + akcja `email_templates_user`)
- UPDATE - `NewsletterController`: lista szablonow ograniczona do szablonow administracyjnych (`is_admin = 1`)
- UPDATE - `email_template_edit` i `template_save` obsluguja tylko szablony administracyjne
- CLEANUP - usuniete nieuzywane szablony newslettera: `admin/templates/newsletter/prepare.php`, `admin/templates/newsletter/preview.php`, `admin/templates/newsletter/email-templates-user.php`
- UPDATE - plik do usuniecia dodany w `updates/0.20/ver_0.258_files.txt`
<hr>
<b>ver. 0.257 - 12.02.2026</b><br />
- NEW - migracja modulu `Newsletter` do architektury Domain + DI (`Domain\Newsletter\NewsletterRepository`, `Domain\Newsletter\NewsletterPreviewRenderer`, `admin\Controllers\NewsletterController`)
- UPDATE - widoki `/admin/newsletter/*` przepiete z legacy `grid/gridEdit` na nowe komponenty (`components/table-list`, `components/form-edit`) + nowy endpoint `/admin/newsletter/preview/`
- UPDATE - routing DI (`admin\Site`) rozszerzony o moduÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡ `Newsletter`
- UPDATE - `admin\factory\Newsletter` dziala jako fasada do nowego repozytorium (backward compatibility)
- UPDATE - `front\factory\Newsletter` nie korzysta juz z `admin\view\Newsletter`
- CLEANUP - usuniete legacy klasy `autoload/admin/controls/class.Newsletter.php`, `autoload/admin/view/class.Newsletter.php`
<hr>
<b>ver. 0.256 - 12.02.2026</b><br />
- NEW - migracja modulu `Layouts` do architektury Domain + DI (`Domain\Layouts\LayoutsRepository`, `admin\Controllers\LayoutsController`)
- UPDATE - lista `/admin/layouts/view_list/` przepieta z legacy `grid` na `components/table-list` (filtry, sortowanie, paginacja)
- UPDATE - `layouts/layout-edit` korzysta z danych z repozytorium (menus/categories), bez wywolan legacy factory w widoku
- UPDATE - `Domain\Languages\LanguagesRepository` rozszerzone o wspolna metode `defaultLanguageId()`
- UPDATE - `admin\Controllers\ArticlesController` pobiera layouty przez `Domain\Layouts\LayoutsRepository` (DI)
- CLEANUP - usuniete legacy klasy `autoload/admin/controls/class.Layouts.php`, `autoload/admin/view/class.Layouts.php`
<hr>
<b>ver. 0.255 - 12.02.2026</b><br />
- UPDATE - kontrolery admin `Settings`, `Banners`, `Dictionaries`, `Articles` pobieraja liste jezykow przez `Domain\Languages\LanguagesRepository` (DI)
- UPDATE - routing DI (`admin\Site`) przekazuje `LanguagesRepository` do kontrolerow `Articles`, `Banners`, `Settings`, `Dictionaries`
- UPDATE - aktywne legacy odwolania (`admin\controls`, `admin\factory\Shop*`) przepiete z `admin\factory\Languages` na `LanguagesRepository`
- FIX - `autoload/admin/factory/class.Languages.php` uzywa pelnego znacznika PHP (zgodnosc z `short_open_tag=Off`)
<hr>
<b>ver. 0.254 - 12.02.2026</b><br />
- UPDATE - modul `Languages` w panelu admin przepiety na `Domain\Languages\LanguagesRepository` + `admin\Controllers\LanguagesController`
- UPDATE - migracja widokow languages (`languages-list`, `language-edit`, `translations-list`, `translation-edit`) na `components/table-list` i `components/form-edit`
- UPDATE - routing DI dla `Languages` w `admin\Site` oraz kompatybilna fasada `admin\factory\Languages` delegujaca do repozytorium
- UPDATE - naprawiono zapis edycji jezyka (ID jezyka pobierane z URL przy edycji)
- UPDATE - globalne poprawki UX filtrÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w w `components/table-list` (kompaktowe kolumny `Aktywny`/`Domyslny`, spacing i pelna szerokosc selecta)
- CLEANUP - usuniete legacy klasy: `autoload/admin/controls/class.Languages.php`, `autoload/admin/view/class.Languages.php`
<hr>
<b>ver. 0.253 - 12.02.2026</b><br />
- UPDATE - modul `Users` w panelu admin w pelni przepiety na `Domain\User\UserRepository` + `admin\Controllers\UsersController`
- UPDATE - migracja widokow users z `grid/gridEdit` na nowe komponenty (`components/table-list`, `components/form-edit`)
- UPDATE - dodana walidacja warunkowa: przy wlaczonym 2FA pole `E-mail do 2FA` jest wymagane
- UPDATE - globalne ulepszenia `components/table-list` (kompaktowe filtry select/status i odstepy w formularzu paginacji)
- CLEANUP - usuniete legacy klasy users: `autoload/admin/controls/class.Users.php`, `autoload/admin/factory/class.Users.php`, `autoload/admin/view/class.Users.php`
<hr>
<b>ver. 0.252 - 10.02.2026</b><br />
- UPDATE - migracja listy archiwum produktow do nowego komponentu tabeli (`components/table-list`) z filtrowaniem i paginacja
- UPDATE - banery i archiwum produktow: wydzielenie CSS/JS do osobnych widokow `*-custom-script.php`
- UPDATE - filemanager przepiety na nowy routing (`admin\Controllers\FilemanagerController`)
- FIX - naprawiono blad `Invalid Key` w widoku filemanagera po refaktoryzacji
- UPDATE - usunieto legacy klasy i stare szablony (`admin\controls`, `admin\view`, `admin/templates/product_archive`)
<hr>
<b>ver. 0.251 - 09.02.2026</b><br />
- NEW - migracja modulu Dictionaries do nowej architektury (Domain + admin Controller + DI)
- UPDATE - lista i formularz Dictionaries przepiete na nowe komponenty (`components/table-list`, `components/form-edit`)
- UPDATE - dodano globalne ograniczenie szerokosci pierwszej kolumny (Lp.) w `components/table-list`
- FIX - zapis tlumaczen jednostek obsluguje `lang_id` jako string (`pl`, `en`)
- UPDATE - usunieto legacy klasy Dictionaries: `admin\controls`, `admin\factory`, `front\factory`
- UPDATE - przepieto uzycia na `Domain\Dictionaries\DictionariesRepository` (`shop-product`, `shop_product` admin)
<hr>
<b>ver. 0.250</b><br />
- UPDATE - refaktoryzacja Settings: `Domain\Settings\SettingsRepository` ma bezposredni dostep do bazy (bez delegacji do `admin\factory\Settings`)
- UPDATE - przepieto pozostale uzycia `admin\factory\Settings` na `Domain\Settings\SettingsRepository` (`admin\controls\Settings`, `admin\controls\Newsletter`, `front\factory\Newsletter`)
- UPDATE - DI dla SettingsController: repozytorium otrzymuje `$mdb` w `admin\Site`
- UPDATE - Settings: widok edycji przeniesiony na nowy mechanizm formularza (`FormEditViewModel` + `components/form-edit`) jak w banerach
- UPDATE - usunieto nieuzywana legacy klase `autoload/admin/factory/class.Settings.php`
- UPDATE - usunieto legacy fallback kontrolera `autoload/admin/controls/class.Settings.php`
- UPDATE - usunieto nieuzywana klase widoku `autoload/admin/view/class.Settings.php`
<hr>
<b>ver. 0.249</b><br />
- FIX - banner edit: poprawiono zapisywanie danych jezykowych i synchronizacje CKEditor przed zapisem
- FIX - banner edit: naprawiono hash zakladek (usunieto `undefined` w URL)
- FIX - filemanager: przywrocono dzialanie popupa wyboru obrazka z banera
- UPDATE - komunikaty zapisu w nowym formularzu sa wyswietlane w stylu panelu (bez natywnego alertu JS)
- UPDATE - lista banerow: dodano kolumne miniatury oraz podglad duzego obrazka w popup po najechaniu
- UPDATE - usunieto nieuzywane legacy klasy banerow: `admin\view\Banners`, `admin\factory\Banners`
<hr>
<b>ver. 0.248</b><br />
- UPDATE - filtry w nowych tabelach dzialaja automatycznie na `onchange`
- UPDATE - `components/table-list`: auto-submit formularza filtrow po zmianie pola (select, date, text)
<hr>
<b>ver. 0.247</b><br />
- UPDATE - nowy dialog potwierdzenia usuwania w `components/table-list` (zamiast natywnego `confirm`)
- UPDATE - popup usuwania: wiekszy rozmiar i centrowanie na srodku ekranu
<hr>
<b>ver. 0.246</b><br />
- UPDATE - migracja listy banerow do nowego mechanizmu tabeli (`components/table-list`, filtrowanie, sortowanie, paginacja)
- UPDATE - `admin\Controllers\BannerController::list()` buduje `PaginatedTableViewModel`
- UPDATE - `Domain\Banner\BannerRepository::listForAdmin()` (bezpieczne filtrowanie i sortowanie)
- UPDATE - usunieto legacy kontroler `autoload/admin/controls/class.Banners.php`
- UPDATE - plik do usuniecia dodany w `updates/0.20/ver_0.246_files.txt`
<hr>
<b>ver. 0.245</b><br />
- UPDATE - refaktoryzacja listy artykulow: wspolny komponent `admin/templates/components/table-list.php` + `PaginatedTableViewModel`
- NEW - `admin\Support\TableListRequestFactory` (wspolna obsluga filtrow, sortowania i paginacji dla list)
- UPDATE - `Domain\Article\ArticleRepository::listForAdmin()` utwardzone pod katem bezpieczenstwa (whitelist sortowania, bind params, limit per_page)
- UPDATE - usunieto legacy `browse_list` dla modulu Articles
- UPDATE - usuniete pliki legacy sa wyszczegolnione w `updates/0.20/ver_0.245_files.txt`
- FIX - generator `.htaccess` i `libraries/htaccess.conf` (QSA dla `/admin/...`, komentarz niedozwolonych dyrektyw `SetHandler/AddHandler/ForceType`)
<hr>
<b>ver. 0.244</b><br />
- UPDATE - refaktoryzacja: article_save przeniesiony do Domain\Article\ArticleRepository::save() z prywatnymi helperami
- UPDATE - refaktoryzacja: article_delete przeniesiony do Domain\Article\ArticleRepository::archive()
- UPDATE - ArticlesController: nowe akcje save() i delete() z DI
- UPDATE - admin\factory\Articles::article_save() i articles_set_archive() delegujÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬ĹˇÄ‚æ do repozytorium (kompatybilnoÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşĂââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬Ä…Ģ€Ç)
<hr>
<b>ver. 0.243</b><br />
- UPDATE - refaktoryzacja: cleanup nieprzypisanych plikÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w/zdjÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ‚ââ¬Ä…Ä‚Ă˜Ä‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬Ä…Ģ€Ç artykuÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡Ä„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w przeniesiony do Domain\Article\ArticleRepository
- UPDATE - ArticlesController::edit() uÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬Ä…ĹĄywa repozytorium do cleanupu, a admin\factory\Articles zachowuje delegowanie (kompatybilnoÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşĂââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬Ä…Ģ€Ç)
<hr>
<b>ver. 0.242</b><br />
- NEW - refaktoryzacja: Domain\Article\ArticleRepository + migracja article_edit do admin\Controllers\ArticlesController (DI)
- UPDATE - admin\factory\Articles::article_details() deleguje do nowego repozytorium (kompatybilnoÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşĂââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬Ä…Ģ€Ç zachowana)
- UPDATE - metody przejÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ‚ââ¬Ä…Ä‚Ă˜te przez nowe kontrolery oznaczone jako @deprecated w legacy kontrolerach admin\controls
<hr>
<b>ver. 0.241</b><br />
- NEW - refaktoryzacja: admin\Controllers\ProductArchiveController - archiwum produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w z DI
- NEW - ProductRepository::archive(), unarchive() - operacje archiwizacji w repozytorium
- FIX - naprawiono SQL w liÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşcie archiwum (puste wyszukiwanie filtrowaÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡o wszystkie wyniki)
- FIX - naprawiono brakujÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬ĹˇÄ‚æcy filtr archive = 1 w zapytaniu bez wyszukiwania
- UPDATE - wyczyszczono szablony archiwum (usuniÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ‚ââ¬Ä…Ä‚Ă˜to zbÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ‚ââ¬Ä…Ä‚Ă˜dne funkcje: apilo, baselinker, duplikowanie)
<hr>
<b>ver. 0.240</b><br />
- NEW - refaktoryzacja: Domain\Settings\SettingsRepository + admin\Controllers\SettingsController (architektura Domain-Driven)
- NEW - refaktoryzacja: Domain\Cache\CacheRepository - czyszczenie cache z obsÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡ugÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬ĹˇÄ‚æ Redis
- FIX - komunikat potwierdzenia zapisu ustawieÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä„Äľ w panelu administratora
- FIX - naprawiono element #content w layoucie admina (powiadomienia grid.js)
<hr>
<b>ver. 0.239</b><br />
- NEW - refaktoryzacja: Domain\Banner\BannerRepository + admin\Controllers\BannerController (peÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡na migracja kontrolera)
- NEW - refaktoryzacja: Domain\Product\ProductRepository::getPrice(), getName() - migracja kolejnych metod
- NEW - router admin z obsÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡ugÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬ĹˇÄ‚æ nowych kontrolerÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w (fallback na stare)
- UPDATE - shop\Product::get_product_price(), get_product_name() uÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬Ä…ĹĄywajÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬ĹˇÄ‚æ nowego repozytorium (kompatybilnoÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşĂââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬Ä…Ģ€Ç zachowana)
<hr>
<b>ver. 0.238</b><br />
- NEW - refaktoryzacja: Domain\Product\ProductRepository - pierwsza klasa w nowej architekturze Domain-Driven
- NEW - Dependency Injection zamiast global variables
- UPDATE - shop\Product::get_product_quantity() uÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬Ä…ĹĄywa teraz nowego repozytorium (kompatybilnoÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşĂââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬Ä…Ģ€Ç zachowana)
<hr>
<b>ver. 0.237</b><br />
- NEW - automatyczne czyszczenie cache produktu po aktualizacji przez CRON (Sellasist, Apilo, Baselinker)
- UPDATE - przycisk "WyczyÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşĂââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬Ä…Ģ€Ç cache" w panelu administratora z obsÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡ugÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬ĹˇÄ‚æ AJAX i komunikatami o postÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ‚ââ¬Ä…Ä‚Ă˜pie
<hr>
<b>ver. 0.236</b><br />
- FIX - zabezpieczenie przed duplikatami zamÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡wieÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä„Äľ w Apilo - automatyczne pobieranie ID zamÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡wienia przy bÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡Ă„‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ‚ââ¬Ä…Ä‚Ă˜dzie "idExternal juÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬Ä…ĹĄ wykorzystywany"
<hr>
<b>ver. 0.235</b><br />
- FIX - poprawka funkcji aktualizacji
<hr>
<b>ver. 0.234</b><br />
- NEW - przycisk zaznaczania zamÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡wienia jako wysÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡ane do trustmate.io
<hr>
<b>ver. 0.232</b><br />
- NEW - opcje GPSR
<hr>
<b>ver. 0.231</b><br />
- FIX - poprawki bezpieczeÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä„Äľstwa + dwuetapowa weryfikacja logowania
<hr>
<b>ver. 0.230</b><br />
- FIX - poprawki bezpieczeÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä„Äľstwa
<hr>
<b>ver. 0.229</b><br />
- NEW - pola dodatkowe z opcjÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬ĹˇÄ‚æ wymagane/niewymagane
<hr>
<b>ver. 0.228</b><br />
- NEW - cron do wysyÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡ania zamÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡wieÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä„Äľ do trustmate.io
<hr>
<b>ver. 0.227</b><br />
- NEW - historia kodÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w rabatowych
<hr>
<b>ver. 0.226</b><br />
- NEW - dodanie opcji faktury do zamÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡wienia
<hr>
<b>ver. 0.225</b><br />
- NEW - przycisk czyszczenia cache
- NEW - ponowne wysyÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡anie zamÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡wienia do apilo
<hr>
<b>ver. 0.224</b><br />
- NEW - sortowanie form dostawy
<hr>
<b>ver. 0.223</b><br />
- FIX - integracja z Orlen Paczka
<hr>
<b>ver. 0.222</b><br />
- NEW - integracja z Orlen Paczka
<hr>
<b>ver. 0.221</b><br />
- NEW - Automatyczne przekierowania adresÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w URL produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w, zmiany w pliku htaccess
<hr>
<b>ver. 0.220</b><br />
- NEW - Dodanie moÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬Ä…ĹĄliwoÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşci wyÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşwietlenia na strone ostatnio dodane produkty [PRODUKTY_NEW] lub [PRODUKTY_NEW:10].
- NEW - Dodanie moÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬Ä…ĹĄliwoÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşci wyÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşwietlenia na strone popularnych produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w [PRODUKTY_TOP] lub [PRODUKTY_TOP:10].
<hr>
<b>ver. 0.219</b><br />
- NEW - Dodanie moÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬Ä…ĹĄliwoÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşci zmiany daty w artykuÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡ach
<hr>
<b>ver. 0.218</b><br />
- NEW - indywidualny kod GTM
<hr>
<b>ver. 0.217</b><br />
- NEW - zwiÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ‚ââ¬Ä…Ä‚Ă˜kszenie obsÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡ugi REDIS
<hr>
<b>ver. 0.216</b><br />
- NEW - aktualizacja api i cron (apilo)
<hr>
<b>ver. 0.215</b><br />
- FIX - generowanie pliku .htaccess
<hr>
<b>ver. 0.214</b><br />
- NEW - dodanie API
<hr>
<b>ver. 0.213</b><br />
- FIX - wyliczenie darmowej dostawy
<hr>
<b>ver. 0.212</b><br />
- NEW - zmiany w zapisywaniu zamÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡wienia do apilo
<hr>
<b>ver. 0.211</b><br />
- NEW - Debugowanie apilo + wyÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşwietlanie podkategorii
<hr>
<b>ver. 0.210</b><br />
- NEW - dodatkowe pola w widoku produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w
<hr>
<b>ver. 0.209</b><br />
- NEW - zmiany w widoku produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w (panel administratora)
<hr>
<b>ver. 0.208</b><br />
- NEW - zmiany w wyszukiwarce produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w
<hr>
<b>ver. 0.204-0.207</b><br />
- NEW - htaccess update
<hr>
<b>ver. 0.204-0.206</b><br />
- NEW - wysyÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡anie produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w do apilo
<hr>
<b>ver. 0.203</b><br />
- NEW - zmiana sposobu wyliczania cen produkty z dodatkami
<hr>
<b>ver. 0.202</b><br />
- NEW - dodano "gÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡Ä„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡wne zdjÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ‚ââ¬Ä…Ä‚Ă˜cie" w edycji artykuÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡u
<hr>
<b>ver. 0.201</b><br />
- FIX - aktualizacja statusÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w na podstawie baselinkera
<hr>
<b>ver. 0.200</b><br />
- NEW - wysyÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡anie produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w do baselinker
<hr>
<b>ver. 0.199</b><br />
- NEW - usprawnienie edycji danych do XML
<hr>
<b>ver. 0.198</b><br />
- NEW - automatyczne generowanie kodÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w SKU
<hr>
<b>ver. 0.197</b><br />
- FIX - poprawki w Dashboard
<hr>
<b>ver. 0.196</b><br />
- FIX - integracja z apilo.com
<hr>
<b>ver. 0.195</b><br />
- FIX - aktualizacja statusÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w
<hr>
<b>ver. 0.194</b><br />
- UPDATE - integracja apilo
<hr>
<b>ver. 0.193</b><br />
- UPDATE - aktualizacja synchronizacji z baselinker
<hr>
<b>ver. 0.192</b><br />
- NEW - pobieranie statusÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w z sellasist
<hr>
<b>ver. 0.191</b><br />
- NEW - integracja z selasist
<hr>
<b>ver. 0.190</b><br />
- FIX - produkty powiÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬ĹˇÄ‚æzane
<hr>
<b>ver. 0.189</b><br />
- FIX - ceny promocyjne produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w z dodatkiem
<hr>
<b>ver. 0.188</b><br />
- NEW - widok listy produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w
<hr>
<b>ver. 0.187</b><br />
- FIX - pobieranie cen z APILO
<hr>
<b>ver. 0.186</b><br />
- FIX - dodawanie do koszyka tych samych produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w ale z rÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬Ä…ĹĄnÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬ĹˇÄ‚æ personalizacjÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬ĹˇÄ‚æ
<hr>
<b>ver. 0.185</b><br />
- FIX - masowa edycja produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w
<hr>
<b>ver. 0.184</b><br />
- NEW - druga czÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ‚ââ¬Ä…Ä‚Ă˜Ä„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşĂââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬Ä…Ģ€Ç integracji z apilo, masowa edycja produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w
<hr>
<b>ver. 0.183</b><br />
- NEW - pierwsza czÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ‚ââ¬Ä…Ä‚Ă˜Ä„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşĂââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬Ä…Ģ€Ç integracji z apilo
<hr>
<b>ver. 0.182</b><br />
- FIX - layout
<hr>
<b>ver. 0.181</b><br />
- NEW - infinitescroll - opcja wÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡Ă„‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬ĹˇÄ‚æczy/wyÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡Ă„‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä‚ââ¬ĹˇÄ‚æcz
<hr>
<b>ver. 0.180</b><br />
- NEW - aktualizacja dashboard
<hr>
<b>ver. 0.179</b><br />
- NEW - obsÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡uga EAN
<hr>
<b>ver. 0.177, 0.178</b><br />
- FIX - custom_label
<hr>
<b>ver. 0.176</b><br />
- NEW - custom_label
<hr>
<b>ver. 0.175</b><br />
- NEW - nowe statystyki
<hr>
<b>ver. 0.174</b><br />
- FIX - generowanie xml
<hr>
<b>ver. 0.173</b><br />
- NEW - duplikowanie produktu wraz z kombinacjami
- NEW - dodanie przechodzenia pomiÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ‚ââ¬Ä…Ä‚Ă˜dzy zamÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡wienia (poprzednie/nastÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ‚ââ¬Ä…Ä‚Ă˜pne zamÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡wienie)
<hr>
<b>ver. 0.172</b><br />
- FIX - poprawki w Cache
<hr>
<b>ver. 0.171</b><br />
- FIX - poprawki w Cache
<hr>
<b>ver. 0.170</b><br />
- NEW - usuwanie cache produktu przy zapisie
<hr>
<b>ver. 0.169</b><br />
- FIX - poprawki w liÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşcie produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w
<hr>
<b>ver. 0.168</b><br />
- NEW - archiwum produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w
<hr>
<b>ver. 0.167</b><br />
- NEW - dodanie obsÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡ugi cen i stanÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w magazynowych kombinacji produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w
<hr>
<b>ver. 0.166</b><br />
- NEW - wspÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡praca z GTM
<hr>
<b>ver. 0.164/5</b><br />
- FIX - ukrywanie produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w nieaktywnych
<hr>
<b>ver. 0.163</b><br />
- NEW - automatyczne podpowiadanie produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w do zestawu na podstawie wczeÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşniejszych zakupÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w klientÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w
<hr>
<b>ver. 0.162</b><br />
- NEW - GA4
<hr>
<b>ver. 0.161</b><br />
- UPDATE - aktualizacja menu administratora
<hr>
<b>ver. 0.160</b><br />
- UPDATE - aktualizacja cron Baselinker
- NEW - waga i cena jednostkowa produktu
<hr>
<b>ver. 0.159</b><br />
- FIX - cron Baselinker
<hr>
<b>ver. 0.158</b><br />
- UPDATE - poprawa kolorystyki przyciskÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w
<hr>
<b>ver. 0.157</b><br />
- NEW - szybka zmiana statusu produktu
<hr>
<b>ver. 0.156</b><br />
- NEW - dodanie szybkiej edycji google xml label
<hr>
<b>ver. 0.155</b><br />
- NEW - infinite scroll w widoku kategorii
<hr>
<b>ver. 0.154</b><br />
- FIX - atrybuty produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w
<hr>
<b>ver. 0.153</b><br />
- FIX - atrybuty produktÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w
<hr>
<b>ver. 0.152</b><br />
- FIX - tematy maili
<hr>
<b>ver. 0.151</b><br />
- FIX - tematy maili
<hr>
<b>ver. 0.150</b><br />
- NEW - domyÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„ąĹşlna forma transportu
<hr>
<b>ver. 0.149</b><br />
- NEW - tematy maili
<hr>
<b>ver. 0.148</b><br />
- FIX - cron-xml
<hr>
<b>ver. 0.147</b><br />
- FIX - cron-xml
<hr>
<b>ver. 0.146</b><br />
- NEW - cron-xml
<hr>
<b>ver. 0.145</b><br />
- NEW - omnibus ready
<hr>
<b>ver. 0.144</b><br />
- FIX - usuniÄ‚ââ¬ĹľÄ˘â‚¬ĹˇÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąÄľÄ‚ââ¬Ä…Ä‚Ă˜cie adresu marianek.pl z kodu
<hr>
<b>ver. 0.143</b><br />
- FIX - poprawa generowania plikÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡w WEBP
<hr>
<b>ver. 0.142</b><br />
- FIX - poprawa adresu strony gÄ„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚ââ¬ĹľÄ˘â‚¬Ă¦Ä„ââ¬ĹˇÄ‹Ă˜Ä‚Ă˜Ä˘â‚¬ĹˇÄ‚Ă¬Ä„Ä…Ä‹ââ¬Ë‡Ä„ââ¬ĹˇÄ˘â‚¬ĹľÄ‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡Ä‚ââ¬ĹľÄ„ââ¬Â¦Ă„‚Ă˜Ä˘ââ¬ĹˇĂ¬ÄąĂ‡wnej
<hr>

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
<?
$current_ver = 323;
$current_ver = 328;
for ($i = 1; $i <= $current_ver; $i++)
{