This commit is contained in:
2026-03-28 00:09:57 +01:00
parent e9e2b1d263
commit d3f4bdaecd
54 changed files with 44 additions and 17134 deletions

View File

@@ -6,6 +6,10 @@
- [feedback_polish_language.md](feedback_polish_language.md) — Zawsze pisz po polsku w tym projekcie - [feedback_polish_language.md](feedback_polish_language.md) — Zawsze pisz po polsku w tym projekcie
- [feedback_screenshots_dir.md](feedback_screenshots_dir.md) — Screenshoty zapisywać w .claude/screenshots/, nie w katalogu głównym - [feedback_screenshots_dir.md](feedback_screenshots_dir.md) — Screenshoty zapisywać w .claude/screenshots/, nie w katalogu głównym
## Project
- [project_psd_personalize.md](project_psd_personalize.md) — Automatyzacja personalizacji PSD: skrypt + Claude review + skill
## Reference ## Reference
- [reference_server_deploy.md](reference_server_deploy.md) — FTP deploy na hostido, vendor/ bez dev deps, usuwanie ręczne - [reference_server_deploy.md](reference_server_deploy.md) — FTP deploy na hostido, vendor/ bez dev deps, usuwanie ręczne

View File

@@ -0,0 +1,20 @@
---
name: PSD Personalize - automatyzacja personalizowanych projektow
description: Workflow automatyzacji personalizacji produktow drukowanych z szablonow PSD — skrypt Python + Claude review
type: project
---
Stworzony workflow automatyzacji personalizacji produktow drukowanych:
- Skrypt `tools/psd_personalize.py` czyta PSD (psd-tools), podmienia tekst w warstwach VAR_*, renderuje podglad PNG
- Claude uczestniczy w procesie: lamie tekst typograficznie (sierotki!), ocenia podglad, sugeruje korekty
- Dane zamowienia przekazywane przez plik JSON (--order) — unika problemow z kodowaniem UTF-8 w bashu
- Rendering: pelny composite PSD jako tlo + nowy tekst rysowany Pillow na wierzchu
- Stary tekst zakrywany bialymi prostokatami (2-fazowe: najpierw wszystkie biale, potem caly tekst)
- Grupa "makieta" (linie ciecia) automatycznie ukrywana
- Tolerancja 3% na wrap_text (roznica kerningu Pillow vs Photoshop)
**Why:** Reczna personalizacja w Photoshopie jest czasochlonna i podatna na bledy typograficzne.
**How to apply:** Przy nowych produktach do personalizacji — ten sam wzorzec: warstwy VAR_*, skrypt, JSON, iteracyjny review.
Pierwszy produkt: Pudelko na pieniadze - Komunia Swieta (Dziewczynka)
Skill: `/pudelko-komunia-dziewczynka`

View File

@@ -26,7 +26,26 @@
"Bash(PROJECT_DIR=\"C:/visual studio code/projekty/orderPRO\" node .claude/hooks/index-docs.mjs)", "Bash(PROJECT_DIR=\"C:/visual studio code/projekty/orderPRO\" node .claude/hooks/index-docs.mjs)",
"mcp__plugin_context-mode_context-mode__ctx_index", "mcp__plugin_context-mode_context-mode__ctx_index",
"mcp__plugin_context-mode_context-mode__ctx_search", "mcp__plugin_context-mode_context-mode__ctx_search",
"mcp__plugin_context-mode_context-mode__ctx_batch_execute" "mcp__plugin_context-mode_context-mode__ctx_batch_execute",
"Bash(powershell.exe -Command \"\\(Get-Item 'C:\\\\Users\\\\jacek\\\\.claude\\\\projects\\\\C--visual-studio-code-projekty-orderPRO\\\\memory'\\).Attributes\")",
"Bash(python tools/psd_personalize.py generate --template \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/pudełka na chrzest - makieta A3 CMYK.psd\" --order \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_order_lenki.json\" --output \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_preview_Lenki.png\")",
"Bash(python tools/psd_personalize.py generate --template \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/pudełka na chrzest - makieta A3 CMYK.psd\" --order \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_order_lenki.json\" --output \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_preview_Lenki_v2.png\")",
"Bash(python tools/psd_personalize.py generate --template \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/pudełka na chrzest - makieta A3 CMYK.psd\" --order \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_order_lenki.json\" --output \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_preview_Lenki_v3.png\")",
"Bash(python tools/psd_personalize.py generate --template \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/pudełka na chrzest - makieta A3 CMYK.psd\" --order \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_order_lenki.json\" --output \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_preview_Lenki_v4.png\")",
"Bash(python tools/psd_personalize.py generate --template \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/pudełka na chrzest - makieta A3 CMYK.psd\" --order \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_order_lenki.json\" --output \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_preview_Lenki_v5.png\")",
"Bash(python tools/psd_personalize.py generate --template \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/pudełka na chrzest - makieta A3 CMYK.psd\" --order \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_order_lenki.json\" --output \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_preview_Lenki_v6.png\")",
"Bash(python tools/psd_personalize.py generate --template \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/pudełka na chrzest - makieta A3 CMYK.psd\" --order \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_order_lenki.json\" --output \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_preview_Lenki_v7.png\")",
"Bash(python tools/psd_personalize.py generate --template \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/pudełka na chrzest - makieta A3 CMYK.psd\" --order \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_order_blanki.json\" --output \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_preview_Blanki_auto.png\")",
"Bash(python tools/psd_personalize.py generate --template \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/pudełka na chrzest - makieta A3 CMYK.psd\" --order \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_order_lenki.json\" --output \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_preview_Lenki_v8.png\")",
"Bash(python tools/psd_personalize.py generate --template \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/pudełka na chrzest - makieta A3 CMYK.psd\" --order \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_order_lenki.json\" --output \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_preview_Lenki_v9.png\")",
"Bash(python tools/psd_personalize.py generate --template \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/pudełka na chrzest - makieta A3 CMYK.psd\" --order \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_order_lenki.json\" --output \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_preview_Lenki_v10.png\")",
"Bash(python tools/psd_personalize.py generate --template \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/pudełka na chrzest - makieta A3 CMYK.psd\" --order \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_order_blanki.json\" --output \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_preview_Blanki_final.png\")",
"Bash(python tools/psd_personalize.py generate --template \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/pudełka na chrzest - makieta A3 CMYK.psd\" --order \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_order_blani.json\" --output \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_preview_Blani.png\")",
"Bash(python tools/psd_personalize.py generate --template \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/pudełka na chrzest - makieta A3 CMYK.psd\" --order \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_order_blani.json\" --output \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_gotowe/Blani.tif\")",
"Bash(pip install:*)",
"Bash(python tools/psd_personalize.py export --template \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/pudełka na chrzest - makieta A3 CMYK.psd\" --order \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_order_blani.json\" --output \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_gotowe/Blani.psd\")",
"Bash(python tools/psd_personalize.py export-ps --template \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/pudełka na chrzest - makieta A3 CMYK.psd\" --order \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_order_blani.json\" --output \"d:/pomysloweprezenty.pl/projekty/komunia święta - pudełka na pieniądze/Pudełko na pieniądze z życzeniami na Komunie Świętą z nadrukiem UV - Dziewczynka/_gotowe/Blani_editable.psd\")",
"Bash(python:*)"
] ]
}, },
"hooks": { "hooks": {

View File

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

View File

@@ -1,7 +0,0 @@
<section class="card">
<h1><?= $e($t('dashboard.title')) ?></h1>
<p class="muted"><?= $e($t('dashboard.description')) ?></p>
<?php if (!empty($user['email'])): ?>
<p><?= $e($t('dashboard.active_user_label')) ?> <span class="accent"><?= $e($user['email']) ?></span></p>
<?php endif; ?>
</section>

View File

@@ -1,47 +0,0 @@
<?php $rows = is_array($integrations ?? null) ? $integrations : []; ?>
<section class="card">
<h1><?= $e($t('marketplace.title')) ?></h1>
<p class="muted"><?= $e($t('marketplace.description')) ?></p>
</section>
<section class="card mt-16">
<h2 class="section-title"><?= $e($t('marketplace.integrations_title')) ?></h2>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
<?php endif; ?>
<?php if ($rows === []): ?>
<p class="muted mt-12"><?= $e($t('marketplace.empty_integrations')) ?></p>
<?php else: ?>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th><?= $e($t('marketplace.fields.integration')) ?></th>
<th><?= $e($t('marketplace.fields.linked_offers_count')) ?></th>
<th><?= $e($t('marketplace.fields.actions')) ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $row): ?>
<?php $integrationId = (int) ($row['id'] ?? 0); ?>
<tr>
<td><?= $e((string) $integrationId) ?></td>
<td><?= $e((string) ($row['name'] ?? '')) ?></td>
<td><?= $e((string) ((int) ($row['linked_offers_count'] ?? 0))) ?></td>
<td>
<a class="btn btn--secondary" href="/marketplace/<?= $e((string) $integrationId) ?>">
<?= $e($t('marketplace.actions.open_offers')) ?>
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>

View File

@@ -1,406 +0,0 @@
<?php $integrationData = is_array($integration ?? null) ? $integration : []; ?>
<?php $rows = is_array($offers ?? null) ? $offers : []; ?>
<?php $integrationId = (int) ($integrationData['id'] ?? 0); ?>
<?php $filters = is_array($filters ?? null) ? $filters : []; ?>
<?php $channelOptions = is_array($channelOptions ?? null) ? $channelOptions : []; ?>
<?php $pagination = is_array($pagination ?? null) ? $pagination : []; ?>
<?php
$currentSort = (string) ($filters['sort'] ?? 'updated_at');
$currentDir = strtoupper((string) ($filters['sort_dir'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
$page = max(1, (int) ($pagination['page'] ?? 1));
$totalPages = max(1, (int) ($pagination['total_pages'] ?? 1));
$total = max(0, (int) ($pagination['total'] ?? count($rows)));
$perPage = max(1, (int) ($pagination['per_page'] ?? 20));
$buildUrl = static function (array $params = []) use ($integrationId, $filters): string {
$merged = array_merge($filters, $params);
foreach ($merged as $key => $value) {
if ($value === '' || $value === null) {
unset($merged[$key]);
}
}
$query = http_build_query($merged);
$base = '/marketplace/' . $integrationId;
return $query !== '' ? ($base . '?' . $query) : $base;
};
?>
<section class="card">
<h1><?= $e($t('marketplace.offers_title', ['name' => (string) ($integrationData['name'] ?? '')])) ?></h1>
<p class="muted"><?= $e($t('marketplace.offers_description')) ?></p>
</section>
<section class="card mt-16">
<a class="btn btn--secondary" href="/marketplace"><?= $e($t('marketplace.actions.back_to_marketplace')) ?></a>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<div class="alert alert--success mt-12" role="status"><?= $e((string) $successMessage) ?></div>
<?php endif; ?>
<form method="get" action="/marketplace/<?= $e((string) $integrationId) ?>" class="table-list-filters mt-12">
<label class="form-field">
<span class="field-label"><?= $e($t('products.filters.search')) ?></span>
<input class="form-control" type="text" name="search" value="<?= $e((string) ($filters['search'] ?? '')) ?>" placeholder="Oferta, SKU, EAN, external ID">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('marketplace.fields.channel')) ?></span>
<select class="form-control" name="channel">
<option value=""><?= $e($t('products.filters.any')) ?></option>
<?php foreach ($channelOptions as $channelName): ?>
<option value="<?= $e((string) $channelName) ?>"<?= (string) ($filters['channel'] ?? '') === (string) $channelName ? ' selected' : '' ?>>
<?= $e((string) $channelName) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.filters.per_page')) ?></span>
<select class="form-control" name="per_page">
<?php foreach ([10, 20, 50, 100] as $opt): ?>
<option value="<?= $e((string) $opt) ?>"<?= $perPage === $opt ? ' selected' : '' ?>><?= $e((string) $opt) ?></option>
<?php endforeach; ?>
</select>
</label>
<input type="hidden" name="sort" value="<?= $e((string) ($filters['sort'] ?? 'updated_at')) ?>">
<input type="hidden" name="sort_dir" value="<?= $e((string) ($filters['sort_dir'] ?? 'DESC')) ?>">
<input type="hidden" name="page" value="1">
<div class="filters-actions">
<button class="btn btn--primary" type="submit"><?= $e($t('products.actions.filter')) ?></button>
<a class="btn btn--secondary" href="/marketplace/<?= $e((string) $integrationId) ?>"><?= $e($t('products.actions.reset')) ?></a>
</div>
</form>
<p class="muted mt-12"><?= $e($t('products.pagination.summary', ['total' => (string) $total])) ?></p>
<?php if ($rows === []): ?>
<p class="muted mt-12"><?= $e($t('marketplace.empty_offers')) ?></p>
<?php else: ?>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th>
<a href="<?= $e($buildUrl(['sort' => 'offer_name', 'sort_dir' => ($currentSort === 'offer_name' && $currentDir === 'ASC') ? 'DESC' : 'ASC', 'page' => 1])) ?>" class="table-sort-link">
<?= $e($t('marketplace.fields.offer_name')) ?><?= $currentSort === 'offer_name' ? ($currentDir === 'ASC' ? ' &uarr;' : ' &darr;') : '' ?>
</a>
</th>
<th>
<a href="<?= $e($buildUrl(['sort' => 'external_product_id', 'sort_dir' => ($currentSort === 'external_product_id' && $currentDir === 'ASC') ? 'DESC' : 'ASC', 'page' => 1])) ?>" class="table-sort-link">
<?= $e($t('marketplace.fields.external_product_id')) ?><?= $currentSort === 'external_product_id' ? ($currentDir === 'ASC' ? ' &uarr;' : ' &darr;') : '' ?>
</a>
</th>
<th>
<a href="<?= $e($buildUrl(['sort' => 'external_variant_id', 'sort_dir' => ($currentSort === 'external_variant_id' && $currentDir === 'ASC') ? 'DESC' : 'ASC', 'page' => 1])) ?>" class="table-sort-link">
<?= $e($t('marketplace.fields.external_variant_id')) ?><?= $currentSort === 'external_variant_id' ? ($currentDir === 'ASC' ? ' &uarr;' : ' &darr;') : '' ?>
</a>
</th>
<th>
<a href="<?= $e($buildUrl(['sort' => 'external_offer_id', 'sort_dir' => ($currentSort === 'external_offer_id' && $currentDir === 'ASC') ? 'DESC' : 'ASC', 'page' => 1])) ?>" class="table-sort-link">
<?= $e($t('marketplace.fields.external_offer_id')) ?><?= $currentSort === 'external_offer_id' ? ($currentDir === 'ASC' ? ' &uarr;' : ' &darr;') : '' ?>
</a>
</th>
<th>
<a href="<?= $e($buildUrl(['sort' => 'channel_name', 'sort_dir' => ($currentSort === 'channel_name' && $currentDir === 'ASC') ? 'DESC' : 'ASC', 'page' => 1])) ?>" class="table-sort-link">
<?= $e($t('marketplace.fields.channel')) ?><?= $currentSort === 'channel_name' ? ($currentDir === 'ASC' ? ' &uarr;' : ' &darr;') : '' ?>
</a>
</th>
<th>
<a href="<?= $e($buildUrl(['sort' => 'product_name', 'sort_dir' => ($currentSort === 'product_name' && $currentDir === 'ASC') ? 'DESC' : 'ASC', 'page' => 1])) ?>" class="table-sort-link">
<?= $e($t('marketplace.fields.product')) ?><?= $currentSort === 'product_name' ? ($currentDir === 'ASC' ? ' &uarr;' : ' &darr;') : '' ?>
</a>
</th>
<th>
<a href="<?= $e($buildUrl(['sort' => 'product_sku', 'sort_dir' => ($currentSort === 'product_sku' && $currentDir === 'ASC') ? 'DESC' : 'ASC', 'page' => 1])) ?>" class="table-sort-link">
SKU<?= $currentSort === 'product_sku' ? ($currentDir === 'ASC' ? ' &uarr;' : ' &darr;') : '' ?>
</a>
</th>
<th>
<a href="<?= $e($buildUrl(['sort' => 'product_ean', 'sort_dir' => ($currentSort === 'product_ean' && $currentDir === 'ASC') ? 'DESC' : 'ASC', 'page' => 1])) ?>" class="table-sort-link">
EAN<?= $currentSort === 'product_ean' ? ($currentDir === 'ASC' ? ' &uarr;' : ' &darr;') : '' ?>
</a>
</th>
<th>
<a href="<?= $e($buildUrl(['sort' => 'updated_at', 'sort_dir' => ($currentSort === 'updated_at' && $currentDir === 'ASC') ? 'DESC' : 'ASC', 'page' => 1])) ?>" class="table-sort-link">
<?= $e($t('marketplace.fields.updated_at')) ?><?= $currentSort === 'updated_at' ? ($currentDir === 'ASC' ? ' &uarr;' : ' &darr;') : '' ?>
</a>
</th>
<th><?= $e($t('marketplace.fields.actions')) ?></th>
<th>Kategorie</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $row): ?>
<?php $productId = (int) ($row['product_id'] ?? 0); ?>
<?php $externalProductId = (int) ($row['external_product_id'] ?? 0); ?>
<tr>
<td><?= $e(trim((string) ($row['offer_name'] ?? '')) !== '' ? (string) ($row['offer_name'] ?? '') : '-') ?></td>
<td><?= $e((string) ($row['external_product_id'] ?? '')) ?></td>
<td><?= $e((string) ($row['external_variant_id'] ?? '')) ?></td>
<td><?= $e((string) ($row['external_offer_id'] ?? '')) ?></td>
<td><?= $e((string) ($row['channel_name'] ?? '')) ?></td>
<td>
<a href="/products/<?= $e((string) $productId) ?>">
<?= $e((string) ($row['product_name'] ?? '')) ?>
</a>
</td>
<td><?= $e((string) ($row['product_sku'] ?? '')) ?></td>
<td><?= $e((string) ($row['product_ean'] ?? '')) ?></td>
<td><?= $e((string) ($row['updated_at'] ?? '')) ?></td>
<td>
<?php if ($externalProductId > 0): ?>
<a
class="btn btn--secondary btn--sm"
href="/marketplace/<?= $e((string) $integrationId) ?>/product/<?= $e((string) $externalProductId) ?>/edit"
><?= $e($t('marketplace.actions.edit_offer')) ?></a>
<?php else: ?>
<span class="muted">-</span>
<?php endif; ?>
</td>
<td>
<button
type="button"
class="btn btn--secondary btn--sm js-assign-categories"
data-integration-id="<?= $e((string) $integrationId) ?>"
data-product-id="<?= $e((string) ($row['external_product_id'] ?? '')) ?>"
>Przypisz kategorie</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="table-list__footer mt-12">
<div class="pagination">
<?php $startPage = max(1, $page - 2); ?>
<?php $endPage = min($totalPages, $page + 2); ?>
<a class="pagination__item<?= $page <= 1 ? ' is-disabled' : '' ?>" href="<?= $e($buildUrl(['page' => 1])) ?>">&laquo;</a>
<a class="pagination__item<?= $page <= 1 ? ' is-disabled' : '' ?>" href="<?= $e($buildUrl(['page' => max(1, $page - 1)])) ?>">&lsaquo;</a>
<?php for ($i = $startPage; $i <= $endPage; $i++): ?>
<a class="pagination__item<?= $i === $page ? ' is-active' : '' ?>" href="<?= $e($buildUrl(['page' => $i])) ?>">
<?= $e((string) $i) ?>
</a>
<?php endfor; ?>
<a class="pagination__item<?= $page >= $totalPages ? ' is-disabled' : '' ?>" href="<?= $e($buildUrl(['page' => min($totalPages, $page + 1)])) ?>">&rsaquo;</a>
<a class="pagination__item<?= $page >= $totalPages ? ' is-disabled' : '' ?>" href="<?= $e($buildUrl(['page' => $totalPages])) ?>">&raquo;</a>
</div>
</div>
<?php endif; ?>
</section>
<!-- Modal: przypisywanie kategorii shopPRO -->
<div id="cat-modal-backdrop" class="jq-alert-modal-backdrop" style="display:none" aria-hidden="true">
<div class="jq-alert-modal" role="dialog" aria-modal="true" aria-labelledby="cat-modal-title" style="max-width:520px;width:100%">
<div class="jq-alert-modal__header">
<h3 id="cat-modal-title">Przypisz kategorie</h3>
</div>
<div class="jq-alert-modal__body" style="max-height:420px;overflow-y:auto">
<div id="cat-modal-loading">Ładowanie kategorii...</div>
<div id="cat-modal-error" class="alert alert--danger" style="display:none"></div>
<div id="cat-modal-tree" style="display:none"></div>
</div>
<div class="jq-alert-modal__footer">
<button type="button" class="btn btn--secondary" id="cat-modal-cancel">Anuluj</button>
<button type="button" class="btn btn--primary" id="cat-modal-save" style="display:none">Zapisz</button>
</div>
</div>
</div>
<script>
(function () {
'use strict';
var CSRF = <?= json_encode($csrfToken ?? '') ?>;
var backdrop = document.getElementById('cat-modal-backdrop');
var treeEl = document.getElementById('cat-modal-tree');
var loadingEl = document.getElementById('cat-modal-loading');
var errorEl = document.getElementById('cat-modal-error');
var saveBtn = document.getElementById('cat-modal-save');
var cancelBtn = document.getElementById('cat-modal-cancel');
var state = { integrationId: 0, productId: 0, cachedCategories: null, cachedIntegrationId: 0 };
// Open modal on button click
document.addEventListener('click', function (e) {
var btn = e.target.closest('.js-assign-categories');
if (!btn) return;
state.integrationId = parseInt(btn.dataset.integrationId, 10) || 0;
state.productId = parseInt(btn.dataset.productId, 10) || 0;
if (state.integrationId <= 0 || state.productId <= 0) return;
openModal();
loadData();
});
function openModal() {
backdrop.style.display = '';
backdrop.getBoundingClientRect(); // force reflow so CSS transition fires
backdrop.setAttribute('aria-hidden', 'false');
backdrop.classList.add('is-visible');
loadingEl.style.display = '';
treeEl.style.display = 'none';
errorEl.style.display = 'none';
saveBtn.style.display = 'none';
treeEl.innerHTML = '';
}
function closeModal() {
backdrop.classList.remove('is-visible');
backdrop.style.display = 'none';
backdrop.setAttribute('aria-hidden', 'true');
}
cancelBtn.addEventListener('click', closeModal);
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) closeModal(); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && backdrop.style.display !== 'none') closeModal();
});
// Load categories + current product categories in parallel
function loadData() {
var iid = state.integrationId;
var pid = state.productId;
var categoriesPromise;
if (state.cachedIntegrationId === iid && state.cachedCategories !== null) {
categoriesPromise = Promise.resolve(state.cachedCategories);
} else {
categoriesPromise = fetch('/marketplace/' + iid + '/categories', {
headers: { 'Accept': 'application/json' }
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.ok) throw new Error(d.message || 'Błąd pobierania kategorii');
state.cachedCategories = d.categories;
state.cachedIntegrationId = iid;
return d.categories;
});
}
var currentPromise = fetch('/marketplace/' + iid + '/product/' + pid + '/categories', {
headers: { 'Accept': 'application/json' }
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.ok) throw new Error(d.message || 'Błąd pobierania kategorii produktu');
return d.current_category_ids || [];
});
Promise.all([categoriesPromise, currentPromise])
.then(function (res) { renderTree(res[0], res[1]); })
.catch(function (err) { showError(err.message || 'Nieznany błąd'); });
}
// Build tree from flat list
function buildTree(flat) {
var map = {}, roots = [];
flat.forEach(function (c) { map[c.id] = { id: c.id, parent_id: c.parent_id, title: c.title, children: [] }; });
flat.forEach(function (c) {
if (c.parent_id && map[c.parent_id]) {
map[c.parent_id].children.push(map[c.id]);
} else {
roots.push(map[c.id]);
}
});
return roots;
}
function renderNode(node, checked) {
var li = document.createElement('li');
li.style.cssText = 'list-style:none;padding:0';
var row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:6px;padding:3px 0';
if (node.children.length > 0) {
var toggle = document.createElement('button');
toggle.type = 'button';
toggle.textContent = '▶';
toggle.style.cssText = 'background:none;border:none;cursor:pointer;font-size:10px;padding:0 2px;color:#666';
toggle.addEventListener('click', function () {
var sub = li.querySelector('ul');
if (sub) { sub.hidden = !sub.hidden; toggle.textContent = sub.hidden ? '▶' : '▼'; }
});
row.appendChild(toggle);
} else {
var sp = document.createElement('span');
sp.style.display = 'inline-block'; sp.style.width = '16px';
row.appendChild(sp);
}
var label = document.createElement('label');
label.style.cssText = 'display:flex;align-items:center;gap:5px;cursor:pointer';
var cb = document.createElement('input');
cb.type = 'checkbox'; cb.value = String(node.id);
cb.checked = checked.indexOf(node.id) !== -1;
label.appendChild(cb);
label.appendChild(document.createTextNode(node.title));
row.appendChild(label);
li.appendChild(row);
if (node.children.length > 0) {
var ul = document.createElement('ul');
ul.style.cssText = 'padding-left:20px;margin:0';
node.children.forEach(function (ch) { ul.appendChild(renderNode(ch, checked)); });
li.appendChild(ul);
}
return li;
}
function renderTree(flat, checked) {
treeEl.innerHTML = '';
var roots = buildTree(flat);
var ul = document.createElement('ul');
ul.style.cssText = 'padding:0;margin:0';
if (roots.length === 0) {
treeEl.textContent = 'Brak dostępnych kategorii.';
} else {
roots.forEach(function (r) { ul.appendChild(renderNode(r, checked)); });
treeEl.appendChild(ul);
}
loadingEl.style.display = 'none';
treeEl.style.display = '';
saveBtn.style.display = '';
}
function showError(msg) {
loadingEl.style.display = 'none';
errorEl.textContent = msg;
errorEl.style.display = '';
}
// Save
saveBtn.addEventListener('click', function () {
var cbs = treeEl.querySelectorAll('input[type=checkbox]:checked');
var ids = [];
cbs.forEach(function (cb) { var id = parseInt(cb.value, 10); if (id > 0) ids.push(id); });
saveBtn.disabled = true;
saveBtn.textContent = 'Zapisuję...';
fetch('/marketplace/' + state.integrationId + '/product/' + state.productId + '/categories', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ _token: CSRF, category_ids: ids })
})
.then(function (r) { return r.json(); })
.then(function (d) {
saveBtn.disabled = false; saveBtn.textContent = 'Zapisz';
if (d.ok) {
closeModal();
if (window.OrderProAlerts) window.OrderProAlerts.show({ type: 'success', message: 'Kategorie zapisane.', timeout: 3000 });
} else {
if (window.OrderProAlerts) window.OrderProAlerts.show({ type: 'danger', message: d.message || 'Błąd zapisu.', timeout: 5000 });
}
})
.catch(function (err) {
saveBtn.disabled = false; saveBtn.textContent = 'Zapisz';
if (window.OrderProAlerts) window.OrderProAlerts.show({ type: 'danger', message: 'Błąd sieci: ' + err.message, timeout: 5000 });
});
});
})();
</script>

View File

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

View File

@@ -1,139 +0,0 @@
<section class="card">
<h1><?= $e($t('products.create.title')) ?></h1>
<p class="muted"><?= $e($t('products.create.description')) ?></p>
</section>
<section class="card mt-16">
<?php if (!empty($errors)): ?>
<div class="alert alert--danger" role="alert">
<?php foreach ((array) $errors as $error): ?>
<div><?= $e((string) $error) ?></div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<form class="product-form mt-16" method="post" action="/products">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<div class="form-grid">
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.name')) ?></span>
<input class="form-control" type="text" name="name" required value="<?= $e((string) ($form['name'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label">SKU</span>
<input class="form-control" type="text" name="sku" value="<?= $e((string) ($form['sku'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label">EAN</span>
<input class="form-control" type="text" name="ean" value="<?= $e((string) ($form['ean'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.type')) ?></span>
<select class="form-control" name="type">
<option value="simple"<?= (string) ($form['type'] ?? '') === 'simple' ? ' selected' : '' ?>><?= $e($t('products.type.simple')) ?></option>
<option value="variant_parent"<?= (string) ($form['type'] ?? '') === 'variant_parent' ? ' selected' : '' ?>><?= $e($t('products.type.variant_parent')) ?></option>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.status')) ?></span>
<select class="form-control" name="status">
<option value="1"<?= (string) ($form['status'] ?? '1') === '1' ? ' selected' : '' ?>><?= $e($t('products.status.active')) ?></option>
<option value="0"<?= (string) ($form['status'] ?? '1') === '0' ? ' selected' : '' ?>><?= $e($t('products.status.inactive')) ?></option>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.promoted')) ?></span>
<select class="form-control" name="promoted">
<option value="0"<?= (string) ($form['promoted'] ?? '0') === '0' ? ' selected' : '' ?>><?= $e($t('products.promoted.no')) ?></option>
<option value="1"<?= (string) ($form['promoted'] ?? '0') === '1' ? ' selected' : '' ?>><?= $e($t('products.promoted.yes')) ?></option>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.vat')) ?></span>
<input class="form-control" type="number" step="0.01" min="0" max="100" name="vat" value="<?= $e((string) ($form['vat'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.quantity')) ?></span>
<input class="form-control" type="number" step="0.001" min="0" name="quantity" value="<?= $e((string) ($form['quantity'] ?? '0')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.weight')) ?></span>
<input class="form-control" type="number" step="0.001" min="0" name="weight" value="<?= $e((string) ($form['weight'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.price_input_mode')) ?></span>
<select class="form-control" name="price_input_mode">
<option value="brutto"<?= (string) ($form['price_input_mode'] ?? 'brutto') === 'brutto' ? ' selected' : '' ?>><?= $e($t('products.price_mode.brutto')) ?></option>
<option value="netto"<?= (string) ($form['price_input_mode'] ?? 'brutto') === 'netto' ? ' selected' : '' ?>><?= $e($t('products.price_mode.netto')) ?></option>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.price_brutto')) ?></span>
<input class="form-control" type="number" step="0.01" min="0" name="price_brutto" value="<?= $e((string) ($form['price_brutto'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.price_netto')) ?></span>
<input class="form-control" type="number" step="0.01" min="0" name="price_netto" value="<?= $e((string) ($form['price_netto'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.price_brutto_promo')) ?></span>
<input class="form-control" type="number" step="0.01" min="0" name="price_brutto_promo" value="<?= $e((string) ($form['price_brutto_promo'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.price_netto_promo')) ?></span>
<input class="form-control" type="number" step="0.01" min="0" name="price_netto_promo" value="<?= $e((string) ($form['price_netto_promo'] ?? '')) ?>">
</label>
</div>
<label class="form-field mt-16">
<span class="field-label"><?= $e($t('products.fields.short_description')) ?></span>
<textarea class="form-control" name="short_description" rows="3"><?= $e((string) ($form['short_description'] ?? '')) ?></textarea>
</label>
<label class="form-field mt-12">
<span class="field-label"><?= $e($t('products.fields.description')) ?></span>
<textarea class="form-control" name="description" rows="6"><?= $e((string) ($form['description'] ?? '')) ?></textarea>
</label>
<div class="form-grid mt-16">
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.meta_title')) ?></span>
<input class="form-control" type="text" name="meta_title" value="<?= $e((string) ($form['meta_title'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.meta_description')) ?></span>
<input class="form-control" type="text" name="meta_description" value="<?= $e((string) ($form['meta_description'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.meta_keywords')) ?></span>
<input class="form-control" type="text" name="meta_keywords" value="<?= $e((string) ($form['meta_keywords'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.seo_link')) ?></span>
<input class="form-control" type="text" name="seo_link" value="<?= $e((string) ($form['seo_link'] ?? '')) ?>">
</label>
</div>
<div class="form-actions mt-16">
<button class="btn btn--primary" type="submit"><?= $e($t('products.actions.save')) ?></button>
<a class="btn btn--secondary" href="/products"><?= $e($t('products.actions.back')) ?></a>
</div>
</form>
</section>

View File

@@ -1,626 +0,0 @@
<link href="https://cdn.quilljs.com/1.3.7/quill.snow.css" rel="stylesheet">
<style>
.wysiwyg-wrap { position: relative; z-index: 1; }
.wysiwyg-wrap .ql-toolbar { border-radius: 4px 4px 0 0; border-color: var(--c-border, #d1d5db); background: #f8fafc; }
.wysiwyg-wrap .ql-container { height: auto; border-radius: 0 0 4px 4px; border-color: var(--c-border, #d1d5db); font-size: 14px; font-family: inherit; }
.wysiwyg-wrap .ql-editor { min-height: var(--editor-min-height, 80px); }
</style>
<?php
$integrationEditMode = (bool) ($integrationEditMode ?? false);
$productFormAction = (string) ($productFormAction ?? '/products/update');
$productBackUrl = (string) ($productBackUrl ?? '/products');
?>
<section class="card">
<h1><?= $e((string) ($title ?? $t('products.edit.title', ['id' => (string) ($productId ?? 0)]))) ?></h1>
<p class="muted"><?= $e($t('products.edit.description')) ?></p>
<?php if ($integrationEditMode): ?>
<p class="muted mt-8">Tryb integracyjny: zapis aktualizuje bezposrednio produkt w shopPRO i synchronizuje dane lokalne.</p>
<?php endif; ?>
</section>
<section class="card mt-16">
<?php if (!empty($errors)): ?>
<div class="alert alert--danger" role="alert">
<?php foreach ((array) $errors as $error): ?>
<div><?= $e((string) $error) ?></div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php $images = is_array($productImages ?? null) ? $productImages : []; ?>
<form class="product-form mt-16" method="post" action="<?= $e($productFormAction) ?>" enctype="multipart/form-data">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="id" value="<?= $e((string) ($productId ?? 0)) ?>">
<input type="hidden" id="product-image-csrf" value="<?= $e($csrfToken ?? '') ?>">
<div class="form-grid">
<div class="form-field">
<span class="field-label">SKU</span>
<input class="form-control" type="text" id="product-sku-input" name="sku" value="<?= $e((string) ($form['sku'] ?? '')) ?>">
<button type="button" class="btn btn--secondary mt-12" id="product-generate-sku-btn"><?= $e($t('products.actions.generate_next_sku')) ?></button>
</div>
<label class="form-field">
<span class="field-label">EAN</span>
<input class="form-control" type="text" name="ean" value="<?= $e((string) ($form['ean'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.type')) ?></span>
<select class="form-control" name="type">
<option value="simple"<?= (string) ($form['type'] ?? '') === 'simple' ? ' selected' : '' ?>><?= $e($t('products.type.simple')) ?></option>
<option value="variant_parent"<?= (string) ($form['type'] ?? '') === 'variant_parent' ? ' selected' : '' ?>><?= $e($t('products.type.variant_parent')) ?></option>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.status')) ?></span>
<select class="form-control" name="status">
<option value="1"<?= (string) ($form['status'] ?? '1') === '1' ? ' selected' : '' ?>><?= $e($t('products.status.active')) ?></option>
<option value="0"<?= (string) ($form['status'] ?? '1') === '0' ? ' selected' : '' ?>><?= $e($t('products.status.inactive')) ?></option>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.promoted')) ?></span>
<select class="form-control" name="promoted">
<option value="0"<?= (string) ($form['promoted'] ?? '0') === '0' ? ' selected' : '' ?>><?= $e($t('products.promoted.no')) ?></option>
<option value="1"<?= (string) ($form['promoted'] ?? '0') === '1' ? ' selected' : '' ?>><?= $e($t('products.promoted.yes')) ?></option>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.vat')) ?></span>
<input class="form-control" type="number" step="0.01" min="0" max="100" name="vat" value="<?= $e((string) ($form['vat'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.quantity')) ?></span>
<input class="form-control" type="number" step="0.001" min="0" name="quantity" value="<?= $e((string) ($form['quantity'] ?? '0')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.weight')) ?></span>
<input class="form-control" type="number" step="0.001" min="0" name="weight" value="<?= $e((string) ($form['weight'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.price_input_mode')) ?></span>
<select class="form-control" name="price_input_mode">
<option value="brutto"<?= (string) ($form['price_input_mode'] ?? 'brutto') === 'brutto' ? ' selected' : '' ?>><?= $e($t('products.price_mode.brutto')) ?></option>
<option value="netto"<?= (string) ($form['price_input_mode'] ?? 'brutto') === 'netto' ? ' selected' : '' ?>><?= $e($t('products.price_mode.netto')) ?></option>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.price_brutto')) ?></span>
<input class="form-control" type="number" step="0.01" min="0" name="price_brutto" value="<?= $e((string) ($form['price_brutto'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.price_netto')) ?></span>
<input class="form-control" type="number" step="0.01" min="0" name="price_netto" value="<?= $e((string) ($form['price_netto'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.price_brutto_promo')) ?></span>
<input class="form-control" type="number" step="0.01" min="0" name="price_brutto_promo" value="<?= $e((string) ($form['price_brutto_promo'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.price_netto_promo')) ?></span>
<input class="form-control" type="number" step="0.01" min="0" name="price_netto_promo" value="<?= $e((string) ($form['price_netto_promo'] ?? '')) ?>">
</label>
</div>
<div class="form-grid mt-16">
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.meta_title')) ?></span>
<input class="form-control" type="text" name="meta_title" value="<?= $e((string) ($form['meta_title'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.meta_description')) ?></span>
<input class="form-control" type="text" name="meta_description" value="<?= $e((string) ($form['meta_description'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.meta_keywords')) ?></span>
<input class="form-control" type="text" name="meta_keywords" value="<?= $e((string) ($form['meta_keywords'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.seo_link')) ?></span>
<input class="form-control" type="text" name="seo_link" value="<?= $e((string) ($form['seo_link'] ?? '')) ?>">
</label>
</div>
<?php
$activeIntegrations = is_array($activeIntegrations ?? null) ? $activeIntegrations : [];
$integrationTranslationsMap = is_array($integrationTranslationsMap ?? null) ? $integrationTranslationsMap : [];
?>
<div class="content-tabs-card mt-16">
<div class="content-tabs-nav" id="content-tabs-nav">
<button type="button" class="content-tab-btn is-active" data-tab="global">
<?= $e($t('products.content_tabs.global')) ?>
</button>
<?php foreach ($activeIntegrations as $integration): ?>
<?php $intId = (int) ($integration['id'] ?? 0); ?>
<?php if ($intId <= 0) continue; ?>
<button type="button" class="content-tab-btn" data-tab="integration-<?= $e((string) $intId) ?>">
<?= $e((string) ($integration['name'] ?? '#' . $intId)) ?>
</button>
<?php endforeach; ?>
</div>
<!-- GLOBAL TAB -->
<div class="content-tab-panel is-active" id="content-tab-global">
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.name')) ?> *</span>
<input class="form-control" type="text" name="name" required value="<?= $e((string) ($form['name'] ?? '')) ?>">
</label>
<div class="form-field mt-12">
<span class="field-label"><?= $e($t('products.fields.short_description')) ?></span>
<div class="wysiwyg-wrap">
<div id="editor-short-description"></div>
</div>
<textarea name="short_description" id="input-short-description" style="display:none"><?= $e((string) ($form['short_description'] ?? '')) ?></textarea>
</div>
<div class="form-field mt-12">
<span class="field-label"><?= $e($t('products.fields.description')) ?></span>
<div class="wysiwyg-wrap" style="--editor-min-height:180px">
<div id="editor-description"></div>
</div>
<textarea name="description" id="input-description" style="display:none"><?= $e((string) ($form['description'] ?? '')) ?></textarea>
</div>
</div>
<!-- PER-INTEGRATION TABS -->
<?php foreach ($activeIntegrations as $integration): ?>
<?php
$intId = (int) ($integration['id'] ?? 0);
if ($intId <= 0) continue;
$intData = $integrationTranslationsMap[$intId] ?? [];
$intName = isset($intData['name']) ? (string) $intData['name'] : '';
$intShort = isset($intData['short_description']) ? (string) $intData['short_description'] : '';
$intDesc = isset($intData['description']) ? (string) $intData['description'] : '';
?>
<div class="content-tab-panel" id="content-tab-integration-<?= $e((string) $intId) ?>">
<p class="muted" style="margin-bottom:8px">
Puste pole = używana wartość globalna.
</p>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.name')) ?></span>
<input class="form-control" type="text"
name="integration_content[<?= $e((string) $intId) ?>][name]"
value="<?= $e($intName) ?>">
</label>
<div class="form-field mt-12">
<span class="field-label"><?= $e($t('products.fields.short_description')) ?></span>
<div class="wysiwyg-wrap">
<div id="editor-int-short-<?= $e((string) $intId) ?>"></div>
</div>
<textarea name="integration_content[<?= $e((string) $intId) ?>][short_description]"
id="input-int-short-<?= $e((string) $intId) ?>"
style="display:none"><?= $e($intShort) ?></textarea>
</div>
<div class="form-field mt-12">
<span class="field-label"><?= $e($t('products.fields.description')) ?></span>
<div class="wysiwyg-wrap" style="--editor-min-height:180px">
<div id="editor-int-desc-<?= $e((string) $intId) ?>"></div>
</div>
<textarea name="integration_content[<?= $e((string) $intId) ?>][description]"
id="input-int-desc-<?= $e((string) $intId) ?>"
style="display:none"><?= $e($intDesc) ?></textarea>
</div>
</div>
<?php endforeach; ?>
</div>
<?php if (!$integrationEditMode): ?>
<section class="card mt-16">
<h3><?= $e($t('products.images.title')) ?></h3>
<p class="muted"><?= $e($t('products.images.description')) ?></p>
<div class="product-images-grid mt-12" id="product-images-grid" data-product-id="<?= $e((string) ($productId ?? 0)) ?>">
<?php foreach ($images as $image): ?>
<?php
$imageId = (int) ($image['id'] ?? 0);
$isMain = (int) ($image['is_main'] ?? 0) === 1;
$publicUrl = (string) ($image['public_url'] ?? '');
?>
<article
class="product-image-card<?= $isMain ? ' is-main' : '' ?>"
data-image-id="<?= $e((string) $imageId) ?>"
data-storage-path="<?= $e((string) ($image['storage_path'] ?? '')) ?>"
>
<div class="product-image-card__thumb-wrap">
<?php if ($publicUrl !== ''): ?>
<img class="product-image-card__thumb" src="<?= $e($publicUrl) ?>" alt="<?= $e((string) ($image['alt'] ?? '')) ?>">
<?php else: ?>
<div class="product-image-card__thumb is-empty">NO IMAGE</div>
<?php endif; ?>
<span class="product-image-card__badge"><?= $e($t('products.images.main')) ?></span>
</div>
<div class="product-image-card__meta"><?= $e((string) ($image['storage_path'] ?? '')) ?></div>
<div class="product-image-card__actions">
<button type="button" class="btn btn--secondary btn-set-main"<?= $isMain ? ' disabled' : '' ?>>
<?= $e($t('products.images.set_main')) ?>
</button>
<button type="button" class="btn btn--danger btn-delete-image">
<?= $e($t('products.images.remove')) ?>
</button>
</div>
</article>
<?php endforeach; ?>
</div>
<p class="muted mt-12" id="product-images-empty"<?= $images === [] ? '' : ' style="display:none;"' ?>>
<?= $e($t('products.images.empty')) ?>
</p>
<label class="form-field mt-16">
<span class="field-label"><?= $e($t('products.images.add_new')) ?></span>
<input class="form-control" type="file" id="product-image-upload" name="new_images[]" accept=".jpg,.jpeg,.png,.webp,.gif,image/*" multiple>
</label>
<p class="muted" id="product-image-upload-status"></p>
<p class="muted"><?= $e($t('products.images.main_hint')) ?></p>
</section>
<?php endif; ?>
<div class="form-actions mt-16">
<button class="btn btn--primary" type="submit"><?= $e($t('products.actions.save')) ?></button>
<a class="btn btn--secondary" href="<?= $e($productBackUrl) ?>"><?= $e($t('products.actions.back')) ?></a>
</div>
</form>
</section>
<script>
(function() {
var skuInput = document.getElementById('product-sku-input');
var generateSkuBtn = document.getElementById('product-generate-sku-btn');
var tokenInput = document.getElementById('product-image-csrf');
if (!skuInput || !generateSkuBtn || !tokenInput) return;
var csrfToken = tokenInput.value || '';
var errTitle = <?= json_encode((string) $t('products.sku_generator.confirm_title'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var errDefault = <?= json_encode((string) $t('products.sku_generator.failed'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
function showError(message) {
if (window.OrderProAlerts && typeof window.OrderProAlerts.alert === 'function') {
window.OrderProAlerts.alert({
title: errTitle,
message: message || errDefault,
danger: true
});
return;
}
var uploadStatus = document.getElementById('product-image-upload-status');
if (uploadStatus) {
uploadStatus.textContent = message || errDefault;
}
}
generateSkuBtn.addEventListener('click', async function() {
generateSkuBtn.disabled = true;
try {
var payload = new FormData();
payload.append('_token', csrfToken);
var response = await fetch('/products/next-sku', {
method: 'POST',
body: payload,
credentials: 'same-origin'
});
var result = await response.json();
if (!response.ok || result.ok !== true || !result.sku) {
throw new Error(result.message || errDefault);
}
skuInput.value = String(result.sku);
} catch (error) {
showError((error && error.message) ? error.message : errDefault);
} finally {
generateSkuBtn.disabled = false;
}
});
})();
</script>
<script>
(function() {
var grid = document.getElementById('product-images-grid');
var emptyState = document.getElementById('product-images-empty');
var uploadInput = document.getElementById('product-image-upload');
var uploadStatus = document.getElementById('product-image-upload-status');
var tokenInput = document.getElementById('product-image-csrf');
if (!grid || !uploadInput || !tokenInput) return;
var productId = grid.getAttribute('data-product-id');
var csrfToken = tokenInput.value || '';
var txtSetMain = <?= json_encode((string) $t('products.images.set_main'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var txtRemove = <?= json_encode((string) $t('products.images.remove'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var txtMain = <?= json_encode((string) $t('products.images.main'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var txtUploadPending = <?= json_encode((string) $t('products.images.uploading'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var txtUploadOk = <?= json_encode((string) $t('products.images.uploaded_ok'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var txtDeleteConfirm = <?= json_encode((string) $t('products.images.confirm_delete'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var txtConfirmTitle = <?= json_encode((string) $t('products.images.confirm_title'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var txtConfirmYes = <?= json_encode((string) $t('products.images.confirm_yes'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var txtConfirmNo = <?= json_encode((string) $t('products.images.confirm_no'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
function refreshMainState(mainId) {
var cards = grid.querySelectorAll('.product-image-card');
cards.forEach(function(card) {
var cardId = Number(card.getAttribute('data-image-id') || 0);
var isMain = cardId === mainId;
card.classList.toggle('is-main', isMain);
var setMainBtn = card.querySelector('.btn-set-main');
if (setMainBtn) setMainBtn.disabled = isMain;
});
}
function updateEmptyState() {
if (!emptyState) return;
emptyState.style.display = grid.querySelector('.product-image-card') ? 'none' : '';
}
function buildCard(image) {
var article = document.createElement('article');
article.className = 'product-image-card' + (Number(image.is_main) === 1 ? ' is-main' : '');
article.setAttribute('data-image-id', String(image.id));
article.setAttribute('data-storage-path', String(image.storage_path || ''));
var thumbWrap = document.createElement('div');
thumbWrap.className = 'product-image-card__thumb-wrap';
if (image.public_url) {
var img = document.createElement('img');
img.className = 'product-image-card__thumb';
img.src = image.public_url;
img.alt = image.alt || '';
thumbWrap.appendChild(img);
} else {
var noimg = document.createElement('div');
noimg.className = 'product-image-card__thumb is-empty';
noimg.textContent = 'NO IMAGE';
thumbWrap.appendChild(noimg);
}
var badge = document.createElement('span');
badge.className = 'product-image-card__badge';
badge.textContent = txtMain;
thumbWrap.appendChild(badge);
var meta = document.createElement('div');
meta.className = 'product-image-card__meta';
meta.textContent = image.storage_path || '';
var actions = document.createElement('div');
actions.className = 'product-image-card__actions';
var setMainBtn = document.createElement('button');
setMainBtn.type = 'button';
setMainBtn.className = 'btn btn--secondary btn-set-main';
setMainBtn.textContent = txtSetMain;
if (Number(image.is_main) === 1) setMainBtn.disabled = true;
var removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn--danger btn-delete-image';
removeBtn.textContent = txtRemove;
actions.appendChild(setMainBtn);
actions.appendChild(removeBtn);
article.appendChild(thumbWrap);
article.appendChild(meta);
article.appendChild(actions);
return article;
}
async function postForm(url, data) {
var response = await fetch(url, { method: 'POST', body: data, credentials: 'same-origin' });
var payload = await response.json();
if (!response.ok || payload.ok !== true) {
throw new Error(payload.message || 'Blad operacji.');
}
return payload;
}
uploadInput.addEventListener('change', async function() {
if (!uploadInput.files || uploadInput.files.length === 0) return;
var formData = new FormData();
formData.append('_token', csrfToken);
formData.append('id', String(productId));
Array.prototype.forEach.call(uploadInput.files, function(file) {
formData.append('new_images[]', file);
});
uploadStatus.textContent = txtUploadPending;
uploadInput.disabled = true;
try {
var result = await postForm('/products/images/upload', formData);
(result.images || []).forEach(function(image) {
grid.appendChild(buildCard(image));
if (Number(image.is_main) === 1) refreshMainState(Number(image.id));
});
uploadStatus.textContent = txtUploadOk;
if (result.message) uploadStatus.textContent += ' ' + result.message;
updateEmptyState();
} catch (error) {
uploadStatus.textContent = error.message || 'Blad uploadu.';
} finally {
uploadInput.value = '';
uploadInput.disabled = false;
}
});
grid.addEventListener('click', async function(event) {
var target = event.target;
if (!target || !(target instanceof HTMLElement)) return;
var card = target.closest('.product-image-card');
if (!card) return;
var imageId = Number(card.getAttribute('data-image-id') || 0);
if (imageId <= 0) return;
if (target.classList.contains('btn-set-main')) {
if (target.disabled) return;
var dataMain = new FormData();
dataMain.append('_token', csrfToken);
dataMain.append('id', String(productId));
dataMain.append('image_id', String(imageId));
target.disabled = true;
try {
await postForm('/products/images/set-main', dataMain);
refreshMainState(imageId);
} catch (error) {
target.disabled = false;
if (window.OrderProAlerts && typeof window.OrderProAlerts.alert === 'function') {
window.OrderProAlerts.alert({ title: 'Blad', message: error.message || 'Blad operacji.', danger: true });
} else if (uploadStatus) {
uploadStatus.textContent = error.message || 'Blad operacji.';
}
}
}
if (target.classList.contains('btn-delete-image')) {
var confirmDelete = async function() {
var dataDelete = new FormData();
dataDelete.append('_token', csrfToken);
dataDelete.append('id', String(productId));
dataDelete.append('image_id', String(imageId));
var result = await postForm('/products/images/delete', dataDelete);
card.remove();
if (Number(result.main_image_id || 0) > 0) {
refreshMainState(Number(result.main_image_id));
}
updateEmptyState();
};
if (window.OrderProAlerts && typeof window.OrderProAlerts.confirm === 'function') {
var accepted = await window.OrderProAlerts.confirm({
title: txtConfirmTitle,
message: txtDeleteConfirm,
confirmLabel: txtConfirmYes,
cancelLabel: txtConfirmNo,
danger: true
});
if (!accepted) return;
try { await confirmDelete(); } catch (error) {
window.OrderProAlerts.alert({ title: 'Blad', message: error.message || 'Blad operacji.', danger: true });
}
} else {
try { await confirmDelete(); } catch (error) {
if (uploadStatus) uploadStatus.textContent = error.message || 'Blad operacji.';
}
}
}
});
})();
</script>
<script src="https://cdn.quilljs.com/1.3.7/quill.min.js"></script>
<script>
(function() {
var toolbarShort = [
['bold', 'italic', 'underline'],
[{ list: 'bullet' }],
['link', 'clean']
];
var toolbarFull = [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ list: 'ordered' }, { list: 'bullet' }],
['link', 'clean']
];
var shortInput = document.getElementById('input-short-description');
var descInput = document.getElementById('input-description');
var quillShort = new Quill('#editor-short-description', { theme: 'snow', modules: { toolbar: toolbarShort } });
var quillDesc = new Quill('#editor-description', { theme: 'snow', modules: { toolbar: toolbarFull } });
if (shortInput && shortInput.value) quillShort.clipboard.dangerouslyPasteHTML(shortInput.value);
if (descInput && descInput.value) quillDesc.clipboard.dangerouslyPasteHTML(descInput.value);
// --- per-integration editors ---
var intEditors = []; // array of {shortQuill, descQuill, shortInput, descInput}
document.querySelectorAll('[id^="editor-int-short-"]').forEach(function(el) {
var suffix = el.id.replace('editor-int-short-', '');
var shortEl = el;
var descEl = document.getElementById('editor-int-desc-' + suffix);
var shortInp = document.getElementById('input-int-short-' + suffix);
var descInp = document.getElementById('input-int-desc-' + suffix);
if (!shortEl || !descEl || !shortInp || !descInp) return;
var qShort = new Quill(shortEl, { theme: 'snow', modules: { toolbar: toolbarShort } });
var qDesc = new Quill(descEl, { theme: 'snow', modules: { toolbar: toolbarFull } });
if (shortInp.value) qShort.clipboard.dangerouslyPasteHTML(shortInp.value);
if (descInp.value) qDesc.clipboard.dangerouslyPasteHTML(descInp.value);
intEditors.push({ shortQuill: qShort, descQuill: qDesc, shortInput: shortInp, descInput: descInp });
});
var form = document.querySelector('.product-form');
if (form) {
form.addEventListener('submit', function() {
if (shortInput) shortInput.value = quillShort.root.innerHTML;
if (descInput) descInput.value = quillDesc.root.innerHTML;
intEditors.forEach(function(e) {
e.shortInput.value = e.shortQuill.root.innerHTML;
e.descInput.value = e.descQuill.root.innerHTML;
});
});
}
})();
</script>
<script>
(function() {
var initialTab = <?= json_encode((string) ($initialContentTab ?? ''), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var nav = document.getElementById('content-tabs-nav');
if (!nav) return;
function setTab(tabId) {
if (!tabId) return;
var btn = nav.querySelector('.content-tab-btn[data-tab="' + tabId.replace(/"/g, '\\"') + '"]');
if (!btn) return;
nav.querySelectorAll('.content-tab-btn').forEach(function(b) {
b.classList.remove('is-active');
});
document.querySelectorAll('.content-tab-panel').forEach(function(p) {
p.classList.remove('is-active');
});
btn.classList.add('is-active');
var panel = document.getElementById('content-tab-' + tabId);
if (panel) panel.classList.add('is-active');
}
nav.addEventListener('click', function(e) {
var btn = e.target.closest('.content-tab-btn');
if (!btn) return;
var tabId = btn.getAttribute('data-tab');
if (!tabId) return;
setTab(tabId);
});
if (initialTab) {
setTab(initialTab);
}
})();
</script>

View File

@@ -1,293 +0,0 @@
<section class="card">
<div class="page-head">
<div>
<h1><?= $e($t('products.title')) ?></h1>
<p class="muted"><?= $e($t('products.description')) ?></p>
</div>
</div>
</section>
<?php if (!empty($errorMessage)): ?>
<section class="card mt-16">
<div class="alert alert--danger" role="alert">
<?= $e((string) $errorMessage) ?>
</div>
</section>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<section class="card mt-16">
<div class="alert alert--success" role="status">
<?= $e((string) $successMessage) ?>
</div>
</section>
<?php endif; ?>
<?php require __DIR__ . '/../components/table-list.php'; ?>
<?php
$integrations = is_array($shopProIntegrations ?? null) ? $shopProIntegrations : [];
?>
<div class="modal-backdrop" data-modal-backdrop="product-image-preview-modal" hidden>
<div class="modal modal--image-preview" role="dialog" aria-modal="true" aria-labelledby="product-image-preview-title">
<div class="modal__header">
<h3 id="product-image-preview-title">Podglad zdjecia</h3>
<button type="button" class="btn btn--secondary" data-close-modal="product-image-preview-modal">Zamknij</button>
</div>
<div class="modal__body">
<img src="" alt="" class="product-image-preview__img" data-product-image-preview-target>
</div>
</div>
</div>
<div class="modal-backdrop" data-modal-backdrop="product-import-modal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="product-import-modal-title">
<div class="modal__header">
<h3 id="product-import-modal-title"><?= $e($t('products.import.title')) ?></h3>
<button type="button" class="btn btn--secondary" data-close-modal="product-import-modal"><?= $e($t('products.import.close')) ?></button>
</div>
<form action="/products/import/shoppro" method="post" class="modal__body">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<label class="form-field">
<span class="field-label"><?= $e($t('products.import.integration')) ?></span>
<select class="form-control" name="integration_id" required>
<option value=""><?= $e($t('products.import.integration_placeholder')) ?></option>
<?php foreach ($integrations as $integration): ?>
<option value="<?= $e((string) ($integration['id'] ?? 0)) ?>">
<?= $e((string) ($integration['name'] ?? '')) ?> (ID: <?= $e((string) ($integration['id'] ?? 0)) ?>)
</option>
<?php endforeach; ?>
</select>
</label>
<?php if (empty($integrations)): ?>
<p class="muted"><?= $e($t('products.import.no_integrations')) ?></p>
<?php endif; ?>
<div class="form-field">
<span class="field-label"><?= $e($t('products.import.mode')) ?></span>
<label class="field-inline">
<input type="radio" name="import_mode" value="all">
<?= $e($t('products.import.mode_all')) ?>
</label>
<label class="field-inline">
<input type="radio" name="import_mode" value="single" checked>
<?= $e($t('products.import.mode_single')) ?>
</label>
</div>
<label class="form-field" data-single-id-wrap hidden>
<span class="field-label"><?= $e($t('products.import.external_id')) ?></span>
<input class="form-control" type="number" min="1" name="external_product_id" data-single-id-input>
</label>
<label class="form-field">
<span class="field-label">
<input type="checkbox" name="import_variants" value="1">
<?= $e($t('products.import.with_variants')) ?>
</span>
<small class="muted"><?= $e($t('products.import.with_variants_hint')) ?></small>
</label>
<div class="form-actions mt-16">
<button type="submit" class="btn btn--primary"<?= empty($integrations) ? ' disabled' : '' ?>><?= $e($t('products.import.submit')) ?></button>
</div>
</form>
</div>
</div>
<div class="modal-backdrop" data-modal-backdrop="product-export-modal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="product-export-modal-title">
<div class="modal__header">
<h3 id="product-export-modal-title"><?= $e($t('products.export.title')) ?></h3>
<button type="button" class="btn btn--secondary" data-close-modal="product-export-modal"><?= $e($t('products.export.close')) ?></button>
</div>
<form action="/products/export/shoppro" method="post" class="modal__body" data-export-form>
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<label class="form-field">
<span class="field-label"><?= $e($t('products.export.integration')) ?></span>
<select class="form-control" name="integration_id" required>
<option value=""><?= $e($t('products.export.integration_placeholder')) ?></option>
<?php foreach ($integrations as $integration): ?>
<option value="<?= $e((string) ($integration['id'] ?? 0)) ?>">
<?= $e((string) ($integration['name'] ?? '')) ?> (ID: <?= $e((string) ($integration['id'] ?? 0)) ?>)
</option>
<?php endforeach; ?>
</select>
</label>
<?php if (empty($integrations)): ?>
<p class="muted"><?= $e($t('products.export.no_integrations')) ?></p>
<?php endif; ?>
<div class="form-field">
<span class="field-label"><?= $e($t('products.export.mode')) ?></span>
<label class="field-inline">
<input type="radio" name="export_mode" value="simple" checked>
<?= $e($t('products.export.mode_simple')) ?>
</label>
<label class="field-inline">
<input type="radio" name="export_mode" value="variant">
<?= $e($t('products.export.mode_variant')) ?>
</label>
<small class="muted"><?= $e($t('products.export.mode_hint')) ?></small>
</div>
<div class="form-field">
<span class="field-label"><?= $e($t('products.export.selected_count_label')) ?>: <strong data-export-selected-count>0</strong></span>
<small class="muted"><?= $e($t('products.export.selected_hint')) ?></small>
</div>
<div data-export-selected-wrap></div>
<div class="form-actions mt-16">
<button type="submit" class="btn btn--primary"<?= empty($integrations) ? ' disabled' : '' ?>><?= $e($t('products.export.submit')) ?></button>
</div>
</form>
</div>
</div>
<script>
(function() {
var openBtn = document.querySelector('[data-open-modal="product-import-modal"]');
var backdrop = document.querySelector('[data-modal-backdrop="product-import-modal"]');
var closeBtn = document.querySelector('[data-close-modal="product-import-modal"]');
if (!openBtn || !backdrop || !closeBtn) return;
var modeInputs = backdrop.querySelectorAll('input[name="import_mode"]');
var singleIdWrap = backdrop.querySelector('[data-single-id-wrap]');
var singleIdInput = backdrop.querySelector('[data-single-id-input]');
function syncMode() {
var mode = 'single';
modeInputs.forEach(function(input) {
if (input.checked) mode = input.value;
});
var isSingle = mode === 'single';
if (singleIdWrap) singleIdWrap.hidden = !isSingle;
if (singleIdInput) {
singleIdInput.required = isSingle;
if (!isSingle) singleIdInput.value = '';
}
}
openBtn.addEventListener('click', function() {
backdrop.hidden = false;
syncMode();
});
closeBtn.addEventListener('click', function() {
backdrop.hidden = true;
});
backdrop.addEventListener('click', function(event) {
if (event.target === backdrop) {
backdrop.hidden = true;
}
});
modeInputs.forEach(function(input) {
input.addEventListener('change', syncMode);
});
})();
(function() {
var openBtn = document.querySelector('[data-open-modal="product-export-modal"]');
var backdrop = document.querySelector('[data-modal-backdrop="product-export-modal"]');
var closeBtn = document.querySelector('[data-close-modal="product-export-modal"]');
var exportForm = backdrop ? backdrop.querySelector('[data-export-form]') : null;
var selectedWrap = backdrop ? backdrop.querySelector('[data-export-selected-wrap]') : null;
var selectedCount = backdrop ? backdrop.querySelector('[data-export-selected-count]') : null;
if (!openBtn || !backdrop || !closeBtn || !exportForm || !selectedWrap || !selectedCount) return;
function selectedIds() {
return Array.prototype.slice.call(document.querySelectorAll('.js-table-select-item:checked'))
.map(function(input) { return (input.value || '').trim(); })
.filter(function(value) { return value !== ''; });
}
function renderSelectedIds(ids) {
selectedWrap.innerHTML = '';
ids.forEach(function(id) {
var input = document.createElement('input');
input.type = 'hidden';
input.name = 'export_product_ids[]';
input.value = id;
selectedWrap.appendChild(input);
});
selectedCount.textContent = String(ids.length);
}
function showNoSelectionMessage() {
var message = '<?= $e($t('products.export.flash.no_products_selected')) ?>';
if (window.OrderProAlerts && typeof window.OrderProAlerts.show === 'function') {
window.OrderProAlerts.show({ type: 'error', message: message, timeout: 2500 });
return;
}
window.alert(message);
}
openBtn.addEventListener('click', function() {
renderSelectedIds(selectedIds());
backdrop.hidden = false;
});
closeBtn.addEventListener('click', function() {
backdrop.hidden = true;
});
backdrop.addEventListener('click', function(event) {
if (event.target === backdrop) {
backdrop.hidden = true;
}
});
exportForm.addEventListener('submit', function(event) {
var ids = selectedIds();
renderSelectedIds(ids);
if (ids.length > 0) {
return;
}
event.preventDefault();
showNoSelectionMessage();
});
})();
(function() {
var previewBackdrop = document.querySelector('[data-modal-backdrop="product-image-preview-modal"]');
if (!previewBackdrop) return;
var previewImage = previewBackdrop.querySelector('[data-product-image-preview-target]');
var closeBtn = previewBackdrop.querySelector('[data-close-modal="product-image-preview-modal"]');
if (!previewImage || !closeBtn) return;
function closePreview() {
previewBackdrop.hidden = true;
previewImage.setAttribute('src', '');
}
document.addEventListener('click', function(event) {
var trigger = event.target.closest('[data-product-image-preview]');
if (!trigger) return;
var imageUrl = trigger.getAttribute('data-product-image-preview') || '';
if (imageUrl === '') return;
previewImage.setAttribute('src', imageUrl);
previewBackdrop.hidden = false;
});
closeBtn.addEventListener('click', closePreview);
previewBackdrop.addEventListener('click', function(event) {
if (event.target === previewBackdrop) {
closePreview();
}
});
})();
</script>

View File

@@ -1,268 +0,0 @@
<?php $item = is_array($product ?? null) ? $product : []; ?>
<?php $links = is_array($productLinks ?? null) ? $productLinks : []; ?>
<?php $integrations = is_array($linkIntegrations ?? null) ? $linkIntegrations : []; ?>
<?php $offers = is_array($linkOffers ?? null) ? $linkOffers : []; ?>
<?php $eventsByMap = is_array($productLinkEventsByMap ?? null) ? $productLinkEventsByMap : []; ?>
<?php $selectedIntegrationId = (int) ($selectedLinksIntegrationId ?? 0); ?>
<?php $linksQueryValue = (string) ($linksQuery ?? ''); ?>
<?php $productIdValue = (int) ($productId ?? 0); ?>
<section class="card">
<h1><?= $e($t('products.links.page_title', ['id' => (string) ($productId ?? 0)])) ?></h1>
<p class="muted"><?= $e($t('products.links.description')) ?></p>
</section>
<section class="card mt-16">
<div class="product-tabs-nav">
<a class="btn btn--secondary" href="/products/<?= $e((string) $productIdValue) ?>"><?= $e($t('products.tabs.details')) ?></a>
<span class="btn btn--primary"><?= $e($t('products.tabs.links')) ?></span>
</div>
</section>
<section class="card mt-16">
<div class="product-links-head">
<div>
<strong><?= $e($t('products.fields.name')) ?>:</strong>
<?= $e((string) ($item['name'] ?? '')) ?>
</div>
<div>
<strong>SKU:</strong>
<?= $e((string) ($item['sku'] ?? '')) ?>
</div>
<div>
<strong>EAN:</strong>
<?= $e((string) ($item['ean'] ?? '')) ?>
</div>
</div>
</section>
<section class="card mt-16">
<h3><?= $e($t('products.links.title')) ?></h3>
<?php if (!empty($linksErrorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $linksErrorMessage) ?></div>
<?php endif; ?>
<?php if (!empty($linksSuccessMessage)): ?>
<div class="alert alert--success mt-12" role="status"><?= $e((string) $linksSuccessMessage) ?></div>
<?php endif; ?>
<h4 class="section-title mt-16"><?= $e($t('products.links.current_links')) ?></h4>
<?php if ($links === []): ?>
<p class="muted mt-12"><?= $e($t('products.links.empty_links')) ?></p>
<?php else: ?>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th><?= $e($t('products.links.fields.integration')) ?></th>
<th><?= $e($t('products.links.fields.channel')) ?></th>
<th><?= $e($t('products.links.fields.external_product_id')) ?></th>
<th><?= $e($t('products.links.fields.external_variant_id')) ?></th>
<th><?= $e($t('products.links.fields.link_type')) ?></th>
<th><?= $e($t('products.links.fields.confidence')) ?></th>
<th><?= $e($t('products.links.fields.link_status')) ?></th>
<th><?= $e($t('products.links.fields.updated_at')) ?></th>
<th><?= $e($t('products.links.fields.history')) ?></th>
<th><?= $e($t('products.links.fields.actions')) ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($links as $link): ?>
<?php
$mapId = (int) ($link['id'] ?? 0);
$linkStatus = (string) ($link['link_status'] ?? '');
$isActive = $linkStatus === 'active';
$confidence = $link['confidence'] ?? null;
$hasMissingAlert = ($link['has_missing_alert'] ?? false) === true;
$missingAlertMessage = trim((string) ($link['missing_alert_message'] ?? ''));
if ($missingAlertMessage === '') {
$missingAlertMessage = (string) $t('products.links.alerts.missing_remote_link');
}
$missingAlertFirstDetectedAt = trim((string) ($link['missing_alert_first_detected_at'] ?? ''));
$missingAlertTooltip = $missingAlertMessage;
if ($missingAlertFirstDetectedAt !== '') {
$missingAlertTooltip .= ' ' . (string) $t('products.links.alerts.alert_since', [
'date' => $missingAlertFirstDetectedAt,
]);
}
$lastChangeAt = trim((string) ($link['updated_at'] ?? ''));
if ($lastChangeAt === '') {
$lastChangeAt = trim((string) ($link['linked_at'] ?? ''));
}
?>
<tr>
<td><?= $e((string) (($link['integration_name'] ?? '') !== '' ? $link['integration_name'] : ('#' . (string) ($link['integration_id'] ?? 0)))) ?></td>
<td><?= $e((string) ($link['channel_name'] ?? '')) ?></td>
<td><?= $e((string) ($link['external_product_id'] ?? '')) ?></td>
<td><?= $e((string) ($link['external_variant_id'] ?? '')) ?></td>
<td><?= $e((string) ($link['link_type'] ?? '')) ?></td>
<td><?= $e($confidence === null ? '-' : ((string) $confidence . '%')) ?></td>
<td>
<div class="product-link-status-cell">
<span class="status-pill<?= $isActive ? ' is-active' : '' ?>">
<?= $e($linkStatus) ?>
</span>
<?php if ($hasMissingAlert): ?>
<span class="product-link-alert-indicator" title="<?= $e($missingAlertTooltip) ?>" aria-label="<?= $e($missingAlertTooltip) ?>">!</span>
<?php endif; ?>
</div>
</td>
<td><?= $e($lastChangeAt === '' ? '-' : $lastChangeAt) ?></td>
<td>
<?php $events = is_array($eventsByMap[$mapId] ?? null) ? $eventsByMap[$mapId] : []; ?>
<?php if ($events === []): ?>
<span class="muted">-</span>
<?php else: ?>
<ul class="product-link-events-list">
<?php foreach ($events as $event): ?>
<li>
<span class="product-link-events-type"><?= $e((string) ($event['event_type'] ?? '')) ?></span>
<span class="product-link-events-date"><?= $e((string) ($event['created_at'] ?? '')) ?></span>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</td>
<td>
<div class="product-links-actions-row">
<form action="/products/<?= $e((string) $productIdValue) ?>/links/<?= $e((string) $mapId) ?>/relink" method="post" class="product-links-inline-form product-links-relink-form">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="product_id" value="<?= $e((string) ($productId ?? 0)) ?>">
<input type="hidden" name="map_id" value="<?= $e((string) $mapId) ?>">
<input type="hidden" name="integration_id" value="<?= $e((string) ((int) ($link['integration_id'] ?? 0))) ?>">
<input class="form-control" type="text" name="external_product_id" required value="<?= $e((string) ($link['external_product_id'] ?? '')) ?>">
<input class="form-control" type="text" name="external_variant_id" value="<?= $e((string) ($link['external_variant_id'] ?? '')) ?>" placeholder="<?= $e($t('products.links.fields.external_variant_id_optional')) ?>">
<button type="submit" class="btn btn--secondary" data-links-action="relink"><?= $e($t('products.links.actions.relink')) ?></button>
</form>
<form action="/products/<?= $e((string) $productIdValue) ?>/links/<?= $e((string) $mapId) ?>/unlink" method="post" class="product-links-unlink-form">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="product_id" value="<?= $e((string) ($productId ?? 0)) ?>">
<input type="hidden" name="map_id" value="<?= $e((string) $mapId) ?>">
<button type="submit" class="btn btn--danger" data-links-action="unlink"><?= $e($t('products.links.actions.unlink')) ?></button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<h4 class="section-title mt-16"><?= $e($t('products.links.search_title')) ?></h4>
<form class="product-links-search-form mt-12" action="/products/<?= $e((string) $productIdValue) ?>/links" method="get">
<input type="hidden" name="id" value="<?= $e((string) ($productId ?? 0)) ?>">
<label class="form-field">
<span class="field-label"><?= $e($t('products.links.fields.integration')) ?></span>
<select class="form-control" name="links_integration_id" required>
<option value="0"><?= $e($t('products.links.integration_placeholder')) ?></option>
<?php foreach ($integrations as $integration): ?>
<?php $integrationId = (int) ($integration['id'] ?? 0); ?>
<option value="<?= $e((string) $integrationId) ?>"<?= $integrationId === $selectedIntegrationId ? ' selected' : '' ?>>
<?= $e((string) ($integration['name'] ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.links.fields.search')) ?></span>
<input class="form-control" type="text" name="links_query" value="<?= $e($linksQueryValue) ?>" placeholder="<?= $e($t('products.links.search_placeholder')) ?>">
</label>
<button type="submit" class="btn btn--primary"><?= $e($t('products.links.actions.search')) ?></button>
</form>
<?php if ($offers === []): ?>
<p class="muted mt-12"><?= $e($t('products.links.empty_offers')) ?></p>
<?php else: ?>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th><?= $e($t('products.links.fields.offer_name')) ?></th>
<th>SKU</th>
<th>EAN</th>
<th><?= $e($t('products.links.fields.external_product_id')) ?></th>
<th><?= $e($t('products.links.fields.external_variant_id')) ?></th>
<th><?= $e($t('products.links.fields.match_hint')) ?></th>
<th><?= $e($t('products.links.fields.confidence')) ?></th>
<th><?= $e($t('products.links.fields.actions')) ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($offers as $offer): ?>
<tr>
<td><?= $e((string) ($offer['name'] ?? '')) ?></td>
<td><?= $e((string) ($offer['sku'] ?? '')) ?></td>
<td><?= $e((string) ($offer['ean'] ?? '')) ?></td>
<td><?= $e((string) ($offer['external_product_id'] ?? '')) ?></td>
<td><?= $e((string) ($offer['external_variant_id'] ?? '')) ?></td>
<td><?= $e((string) ($offer['match_hint'] ?? '')) ?></td>
<td><?= $e((string) ((int) ($offer['match_confidence'] ?? 0)) . '%') ?></td>
<td>
<form action="/products/<?= $e((string) $productIdValue) ?>/links" method="post">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="product_id" value="<?= $e((string) ($productId ?? 0)) ?>">
<input type="hidden" name="integration_id" value="<?= $e((string) ($offer['integration_id'] ?? 0)) ?>">
<input type="hidden" name="external_product_id" value="<?= $e((string) ($offer['external_product_id'] ?? '')) ?>">
<input type="hidden" name="external_variant_id" value="<?= $e((string) ($offer['external_variant_id'] ?? '')) ?>">
<button type="submit" class="btn btn--primary"><?= $e($t('products.links.actions.link')) ?></button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
<section class="card mt-16">
<a class="btn btn--secondary" href="/products"><?= $e($t('products.actions.back')) ?></a>
<a class="btn btn--secondary" href="/products/<?= $e((string) $productIdValue) ?>"><?= $e($t('products.actions.preview')) ?></a>
<a class="btn btn--primary" href="/products/edit?id=<?= $e((string) ($productId ?? 0)) ?>"><?= $e($t('products.actions.edit')) ?></a>
</section>
<script>
(function () {
var unlinkForms = document.querySelectorAll('.product-links-unlink-form');
var relinkForms = document.querySelectorAll('.product-links-relink-form');
var unlinkMessage = <?= json_encode((string) $t('products.links.confirm.unlink_message'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var relinkMessage = <?= json_encode((string) $t('products.links.confirm.relink_message'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var confirmTitle = <?= json_encode((string) $t('products.links.confirm.title'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var confirmYes = <?= json_encode((string) $t('products.links.confirm.yes'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var confirmNo = <?= json_encode((string) $t('products.links.confirm.no'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
async function handleConfirmSubmit(event, message, danger) {
if (!window.OrderProAlerts || typeof window.OrderProAlerts.confirm !== 'function') {
return;
}
event.preventDefault();
var accepted = await window.OrderProAlerts.confirm({
title: confirmTitle,
message: message,
confirmLabel: confirmYes,
cancelLabel: confirmNo,
danger: danger === true
});
if (!accepted) {
return;
}
event.target.submit();
}
unlinkForms.forEach(function (form) {
form.addEventListener('submit', function (event) {
handleConfirmSubmit(event, unlinkMessage, true);
});
});
relinkForms.forEach(function (form) {
form.addEventListener('submit', function (event) {
handleConfirmSubmit(event, relinkMessage, false);
});
});
})();
</script>

View File

@@ -1,197 +0,0 @@
<section class="card">
<h1><?= $e($t('products.show.title', ['id' => (string) ($productId ?? 0)])) ?></h1>
<p class="muted"><?= $e($t('products.show.description')) ?></p>
</section>
<?php $item = is_array($product ?? null) ? $product : []; ?>
<?php $images = is_array($productImages ?? null) ? $productImages : []; ?>
<?php $variants = is_array($productVariants ?? null) ? $productVariants : []; ?>
<?php $importWarning = is_array($productImportWarning ?? null) ? $productImportWarning : null; ?>
<?php $productIdValue = (int) ($productId ?? 0); ?>
<?php if (!empty($errorMessage)): ?>
<section class="card mt-16">
<div class="alert alert--danger" role="alert">
<?= $e((string) $errorMessage) ?>
</div>
</section>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<section class="card mt-16">
<div class="alert alert--success" role="status">
<?= $e((string) $successMessage) ?>
</div>
</section>
<?php endif; ?>
<section class="card mt-16">
<div class="product-tabs-nav">
<span class="btn btn--primary"><?= $e($t('products.tabs.details')) ?></span>
<a class="btn btn--secondary" href="/products/<?= $e((string) $productIdValue) ?>/links"><?= $e($t('products.tabs.links')) ?></a>
</div>
</section>
<section class="card mt-16">
<?php if ($importWarning !== null && !empty($importWarning['messages'])): ?>
<div class="alert alert--danger" role="alert">
<div><strong><?= $e($t('products.variants.import_warning_title')) ?></strong></div>
<?php foreach ((array) ($importWarning['messages'] ?? []) as $warning): ?>
<div><?= $e((string) $warning) ?></div>
<?php endforeach; ?>
<?php if (!empty($importWarning['created_at'])): ?>
<div class="muted mt-8"><?= $e($t('products.variants.import_warning_date')) ?>: <?= $e((string) $importWarning['created_at']) ?></div>
<?php endif; ?>
</div>
<?php endif; ?>
<h3><?= $e($t('products.show.details')) ?></h3>
<table class="table table--details mt-12">
<tbody>
<tr><th>ID</th><td><?= $e((string) ($item['id'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.name')) ?></th><td><?= $e((string) ($item['name'] ?? '')) ?></td></tr>
<tr><th>SKU</th><td><?= $e((string) ($item['sku'] ?? '')) ?></td></tr>
<tr>
<th>EAN</th>
<td>
<?php if (trim((string) ($item['ean'] ?? '')) !== ''): ?>
<?= $e((string) $item['ean']) ?>
<?php else: ?>
<span class="muted">—</span>
<form method="post" action="/products/<?= $e((string) $productIdValue) ?>/assign-ean" style="display:inline; margin-left:8px;">
<input type="hidden" name="_token" value="<?= $e((string) ($csrfToken ?? '')) ?>">
<button type="submit" class="btn btn--primary btn--sm"><?= $e($t('products.gs1.assign_ean')) ?></button>
</form>
<?php endif; ?>
</td>
</tr>
<tr><th><?= $e($t('products.fields.type')) ?></th><td><?= $e((string) ($item['type'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.status')) ?></th><td><?= $e((string) ($item['status'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.promoted')) ?></th><td><?= $e((string) ($item['promoted'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.vat')) ?></th><td><?= $e((string) ($item['vat'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.weight')) ?></th><td><?= $e((string) ($item['weight'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.quantity')) ?></th><td><?= $e((string) ($item['quantity'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.price_brutto')) ?></th><td><?= $e((string) ($item['price_brutto'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.price_netto')) ?></th><td><?= $e((string) ($item['price_netto'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.price_brutto_promo')) ?></th><td><?= $e((string) ($item['price_brutto_promo'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.price_netto_promo')) ?></th><td><?= $e((string) ($item['price_netto_promo'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.short_description')) ?></th><td><?= $e((string) ($item['short_description'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.description')) ?></th><td><?= $e((string) ($item['description'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.meta_title')) ?></th><td><?= $e((string) ($item['meta_title'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.meta_description')) ?></th><td><?= $e((string) ($item['meta_description'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.meta_keywords')) ?></th><td><?= $e((string) ($item['meta_keywords'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.seo_link')) ?></th><td><?= $e((string) ($item['seo_link'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.updated_at')) ?></th><td><?= $e((string) ($item['updated_at'] ?? '')) ?></td></tr>
<?php if (!empty($item['producer_name'])): ?>
<tr><th>Producent</th><td><?= $e((string) $item['producer_name']) ?></td></tr>
<?php endif; ?>
<?php if (!empty($item['security_information'])): ?>
<tr>
<th>GPSR — informacje o bezpieczeństwie</th>
<td><?= $e((string) ($item['security_information'] ?? '')) ?></td>
</tr>
<?php endif; ?>
<?php
$customFields = [];
if (!empty($item['custom_fields_json'])) {
$decoded = json_decode((string) $item['custom_fields_json'], true);
$customFields = is_array($decoded) ? $decoded : [];
}
?>
<?php if ($customFields !== []): ?>
<tr>
<th>Dodatkowe pola</th>
<td>
<?php foreach ($customFields as $cf): ?>
<div><?= $e((string) ($cf['name'] ?? '')) ?> (<?= $e((string) ($cf['type'] ?? '')) ?><?= (int) ($cf['is_required'] ?? 0) === 1 ? ', wymagane' : '' ?>)</div>
<?php endforeach; ?>
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</section>
<section class="card mt-16">
<h3><?= $e($t('products.variants.title')) ?></h3>
<?php if ($variants === []): ?>
<p class="muted"><?= $e($t('products.variants.empty')) ?></p>
<?php else: ?>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>SKU</th>
<th>EAN</th>
<th><?= $e($t('products.fields.price_brutto')) ?></th>
<th><?= $e($t('products.fields.price_netto')) ?></th>
<th><?= $e($t('products.fields.weight')) ?></th>
<th><?= $e($t('products.fields.status')) ?></th>
<th><?= $e($t('products.variants.attributes')) ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($variants as $variant): ?>
<?php
$attributes = is_array($variant['attributes'] ?? null) ? $variant['attributes'] : [];
$attributeText = [];
foreach ($attributes as $attribute) {
$attributeName = (string) ($attribute['attribute_name'] ?? '');
$valueName = (string) ($attribute['value_name'] ?? '');
if ($attributeName === '' || $valueName === '') {
continue;
}
$attributeText[] = $attributeName . ': ' . $valueName;
}
?>
<tr>
<td><?= $e((string) ($variant['id'] ?? 0)) ?></td>
<td><?= $e((string) ($variant['sku'] ?? '')) ?></td>
<td><?= $e((string) ($variant['ean'] ?? '')) ?></td>
<td><?= $e((string) ($variant['price_brutto'] ?? '')) ?></td>
<td><?= $e((string) ($variant['price_netto'] ?? '')) ?></td>
<td><?= $e((string) ($variant['weight'] ?? '')) ?></td>
<td><?= $e(((int) ($variant['status'] ?? 0)) === 1 ? $t('products.status.active') : $t('products.status.inactive')) ?></td>
<td><?= $e($attributeText !== [] ? implode(', ', $attributeText) : '-') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
<section class="card mt-16">
<h3><?= $e($t('products.images.title')) ?></h3>
<?php if ($images === []): ?>
<p class="muted"><?= $e($t('products.images.empty')) ?></p>
<?php else: ?>
<div class="product-show-images-grid mt-12">
<?php foreach ($images as $image): ?>
<div class="product-show-image-card">
<div class="product-show-image-card__meta">
<span><strong>ID:</strong> <?= $e((string) ($image['id'] ?? 0)) ?><?= ((int) ($image['is_main'] ?? 0) === 1) ? ' | <strong>' . $e($t('products.images.main')) . '</strong>' : '' ?></span>
<?php if ((string) ($image['storage_path'] ?? '') !== ''): ?>
<details class="product-show-image-path">
<summary><?= $e($t('products.images.path')) ?></summary>
<div class="product-show-image-path__url muted"><?= $e((string) ($image['storage_path'] ?? '')) ?></div>
</details>
<?php endif; ?>
</div>
<?php if ((string) ($image['public_url'] ?? '') !== ''): ?>
<div class="mt-8">
<img src="<?= $e((string) $image['public_url']) ?>" alt="<?= $e((string) ($image['alt'] ?? '')) ?>" class="product-show-image">
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<section class="card mt-16">
<a class="btn btn--secondary" href="/products"><?= $e($t('products.actions.back')) ?></a>
<a class="btn btn--secondary" href="/products/<?= $e((string) $productIdValue) ?>/links"><?= $e($t('products.actions.links')) ?></a>
<a class="btn btn--primary" href="/products/edit?id=<?= $e((string) $productIdValue) ?>"><?= $e($t('products.actions.edit')) ?></a>
</section>

View File

@@ -1,156 +0,0 @@
<?php
$schedulesList = is_array($schedules ?? null) ? $schedules : [];
$futureList = is_array($futureJobs ?? null) ? $futureJobs : [];
$pastList = is_array($pastJobs ?? null) ? $pastJobs : [];
$runOnWeb = ($runOnWebEnabled ?? false) === true;
$webLimit = max(1, (int) ($webCronLimit ?? 5));
?>
<section class="card">
<h1><?= $e($t('settings.title')) ?></h1>
<p class="muted"><?= $e($t('settings.description')) ?></p>
<nav class="settings-nav mt-16" aria-label="<?= $e($t('settings.submenu_label')) ?>">
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'database' ? ' is-active' : '' ?>" href="/settings/database"><?= $e($t('settings.database.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'order_statuses' ? ' is-active' : '' ?>" href="/settings/order-statuses"><?= $e($t('settings.order_statuses.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'cron' ? ' is-active' : '' ?>" href="/settings/cron"><?= $e($t('settings.cron.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'gs1' ? ' is-active' : '' ?>" href="/settings/gs1"><?= $e($t('settings.gs1.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'products' ? ' is-active' : '' ?>" href="/settings/products"><?= $e($t('settings.products.title')) ?></a>
</nav>
</section>
<section class="card mt-16">
<h2 class="section-title"><?= $e($t('settings.cron.run_on_web_title')) ?></h2>
<p class="muted mt-12"><?= $e($t('settings.cron.run_on_web_description')) ?></p>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<div class="alert alert--success mt-12" role="status"><?= $e((string) $successMessage) ?></div>
<?php endif; ?>
<form action="/settings/cron/save" method="post" class="mt-16">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<label class="form-field">
<span class="field-label">
<input type="checkbox" name="cron_run_on_web" value="1"<?= $runOnWeb ? ' checked' : '' ?>>
<?= $e($t('settings.cron.run_on_web_label')) ?>
</span>
</label>
<label class="form-field mt-12">
<span class="field-label"><?= $e($t('settings.cron.web_limit')) ?></span>
<input class="form-control" type="number" min="1" max="100" name="cron_web_limit" value="<?= $e((string) $webLimit) ?>">
</label>
<div class="form-actions mt-16">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.cron.actions.save')) ?></button>
</div>
</form>
</section>
<section class="card mt-16">
<h2 class="section-title"><?= $e($t('settings.cron.schedules_title')) ?></h2>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th><?= $e($t('settings.cron.fields.job_type')) ?></th>
<th><?= $e($t('settings.cron.fields.enabled')) ?></th>
<th><?= $e($t('settings.cron.fields.interval')) ?></th>
<th><?= $e($t('settings.cron.fields.priority')) ?></th>
<th><?= $e($t('settings.cron.fields.next_run_at')) ?></th>
<th><?= $e($t('settings.cron.fields.last_run_at')) ?></th>
</tr>
</thead>
<tbody>
<?php if ($schedulesList === []): ?>
<tr><td colspan="6" class="muted"><?= $e($t('settings.cron.empty_schedules')) ?></td></tr>
<?php else: ?>
<?php foreach ($schedulesList as $row): ?>
<tr>
<td><?= $e((string) ($row['job_type'] ?? '')) ?></td>
<td><?= $e(((bool) ($row['enabled'] ?? false)) ? $t('settings.cron.enabled.yes') : $t('settings.cron.enabled.no')) ?></td>
<td><?= $e((string) ((int) ($row['interval_seconds'] ?? 0))) ?></td>
<td><?= $e((string) ((int) ($row['priority'] ?? 0))) ?></td>
<td><?= $e((string) (($row['next_run_at'] ?? null) ?? '-')) ?></td>
<td><?= $e((string) (($row['last_run_at'] ?? null) ?? '-')) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section class="card mt-16">
<h2 class="section-title"><?= $e($t('settings.cron.future_jobs_title')) ?></h2>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th><?= $e($t('settings.cron.fields.job_type')) ?></th>
<th><?= $e($t('settings.cron.fields.status')) ?></th>
<th><?= $e($t('settings.cron.fields.priority')) ?></th>
<th><?= $e($t('settings.cron.fields.scheduled_at')) ?></th>
<th><?= $e($t('settings.cron.fields.attempts')) ?></th>
</tr>
</thead>
<tbody>
<?php if ($futureList === []): ?>
<tr><td colspan="6" class="muted"><?= $e($t('settings.cron.empty_future_jobs')) ?></td></tr>
<?php else: ?>
<?php foreach ($futureList as $row): ?>
<tr>
<td><?= $e((string) ((int) ($row['id'] ?? 0))) ?></td>
<td><?= $e((string) ($row['job_type'] ?? '')) ?></td>
<td><?= $e((string) ($row['status'] ?? '')) ?></td>
<td><?= $e((string) ((int) ($row['priority'] ?? 0))) ?></td>
<td><?= $e((string) (($row['scheduled_at'] ?? null) ?? '-')) ?></td>
<td><?= $e((string) ((int) ($row['attempts'] ?? 0)) . '/' . (string) ((int) ($row['max_attempts'] ?? 0))) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section class="card mt-16">
<h2 class="section-title"><?= $e($t('settings.cron.past_jobs_title')) ?></h2>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th><?= $e($t('settings.cron.fields.job_type')) ?></th>
<th><?= $e($t('settings.cron.fields.status')) ?></th>
<th><?= $e($t('settings.cron.fields.scheduled_at')) ?></th>
<th><?= $e($t('settings.cron.fields.completed_at')) ?></th>
<th><?= $e($t('settings.cron.fields.last_error')) ?></th>
</tr>
</thead>
<tbody>
<?php if ($pastList === []): ?>
<tr><td colspan="6" class="muted"><?= $e($t('settings.cron.empty_past_jobs')) ?></td></tr>
<?php else: ?>
<?php foreach ($pastList as $row): ?>
<?php $error = trim((string) ($row['last_error'] ?? '')); ?>
<tr>
<td><?= $e((string) ((int) ($row['id'] ?? 0))) ?></td>
<td><?= $e((string) ($row['job_type'] ?? '')) ?></td>
<td><?= $e((string) ($row['status'] ?? '')) ?></td>
<td><?= $e((string) (($row['scheduled_at'] ?? null) ?? '-')) ?></td>
<td><?= $e((string) (($row['completed_at'] ?? null) ?? '-')) ?></td>
<td><?= $e($error === '' ? '-' : $error) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>

View File

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

View File

@@ -1,58 +0,0 @@
<section class="card">
<h1><?= $e($t('settings.title')) ?></h1>
<p class="muted"><?= $e($t('settings.description')) ?></p>
<nav class="settings-nav mt-16" aria-label="<?= $e($t('settings.submenu_label')) ?>">
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'database' ? ' is-active' : '' ?>" href="/settings/database"><?= $e($t('settings.database.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'order_statuses' ? ' is-active' : '' ?>" href="/settings/order-statuses"><?= $e($t('settings.order_statuses.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'cron' ? ' is-active' : '' ?>" href="/settings/cron"><?= $e($t('settings.cron.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'gs1' ? ' is-active' : '' ?>" href="/settings/gs1"><?= $e($t('settings.gs1.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'products' ? ' is-active' : '' ?>" href="/settings/products"><?= $e($t('settings.products.title')) ?></a>
</nav>
</section>
<section class="card mt-16">
<h2 class="section-title"><?= $e($t('settings.gs1.title')) ?></h2>
<p class="muted mt-12"><?= $e($t('settings.gs1.description')) ?></p>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<div class="alert alert--success mt-12" role="status"><?= $e((string) $successMessage) ?></div>
<?php endif; ?>
<form action="/settings/gs1/save" method="post" class="mt-16">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<label class="form-field">
<span class="field-label"><?= $e($t('settings.gs1.fields.api_login')) ?></span>
<input class="form-control" type="text" name="gs1_api_login" value="<?= $e((string) ($gs1ApiLogin ?? '')) ?>" autocomplete="off">
</label>
<label class="form-field mt-12">
<span class="field-label"><?= $e($t('settings.gs1.fields.api_password')) ?></span>
<input class="form-control" type="password" name="gs1_api_password" value="<?= $e((string) ($gs1ApiPassword ?? '')) ?>" autocomplete="off">
</label>
<label class="form-field mt-12">
<span class="field-label"><?= $e($t('settings.gs1.fields.prefix')) ?></span>
<input class="form-control" type="text" name="gs1_prefix" value="<?= $e((string) ($gs1Prefix ?? '590532390')) ?>" maxlength="12">
</label>
<label class="form-field mt-12">
<span class="field-label"><?= $e($t('settings.gs1.fields.default_brand')) ?></span>
<input class="form-control" type="text" name="gs1_default_brand" value="<?= $e((string) ($gs1DefaultBrand ?? 'marianek.pl')) ?>">
</label>
<label class="form-field mt-12">
<span class="field-label"><?= $e($t('settings.gs1.fields.default_gpc_code')) ?></span>
<input class="form-control" type="text" name="gs1_default_gpc_code" value="<?= $e((string) ($gs1DefaultGpcCode ?? '10008365')) ?>">
</label>
<div class="form-actions mt-16">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.gs1.actions.save')) ?></button>
</div>
</form>
</section>

View File

@@ -1,215 +0,0 @@
<?php
$list = (array) ($integrations ?? []);
$selected = is_array($selectedIntegration ?? null) ? $selectedIntegration : null;
$formValues = is_array($form ?? null) ? $form : [];
$tests = (array) ($recentTests ?? []);
$isEdit = ((int) ($formValues['integration_id'] ?? 0)) > 0;
?>
<section class="card">
<h1><?= $e($t('settings.title')) ?></h1>
<p class="muted"><?= $e($t('settings.description')) ?></p>
<nav class="settings-nav mt-16" aria-label="<?= $e($t('settings.submenu_label')) ?>">
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'database' ? ' is-active' : '' ?>" href="/settings/database"><?= $e($t('settings.database.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'order_statuses' ? ' is-active' : '' ?>" href="/settings/order-statuses"><?= $e($t('settings.order_statuses.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'cron' ? ' is-active' : '' ?>" href="/settings/cron"><?= $e($t('settings.cron.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'gs1' ? ' is-active' : '' ?>" href="/settings/gs1"><?= $e($t('settings.gs1.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'products' ? ' is-active' : '' ?>" href="/settings/products"><?= $e($t('settings.products.title')) ?></a>
</nav>
</section>
<section class="card mt-16">
<h2 class="section-title"><?= $e($t('settings.integrations.list_title')) ?></h2>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert">
<?= $e((string) $errorMessage) ?>
</div>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<div class="alert alert--success mt-12" role="status">
<?= $e((string) $successMessage) ?>
</div>
<?php endif; ?>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th><?= $e($t('settings.integrations.fields.name')) ?></th>
<th><?= $e($t('settings.integrations.fields.base_url')) ?></th>
<th><?= $e($t('settings.integrations.fields.active')) ?></th>
<th><?= $e($t('settings.integrations.fields.last_test')) ?></th>
<th><?= $e($t('settings.integrations.fields.actions')) ?></th>
</tr>
</thead>
<tbody>
<?php if (empty($list)): ?>
<tr>
<td colspan="6" class="muted"><?= $e($t('settings.integrations.empty')) ?></td>
</tr>
<?php else: ?>
<?php foreach ($list as $item): ?>
<?php
$status = (string) ($item['last_test_status'] ?? '');
$statusLabel = $status === 'ok'
? $t('settings.integrations.test_status.ok')
: ($status === 'error' ? $t('settings.integrations.test_status.error') : $t('settings.integrations.test_status.never'));
?>
<tr>
<td><?= $e((string) ($item['id'] ?? 0)) ?></td>
<td><?= $e((string) ($item['name'] ?? '')) ?></td>
<td><?= $e((string) ($item['base_url'] ?? '')) ?></td>
<td>
<?php if ((bool) ($item['is_active'] ?? false)): ?>
<span class="status-pill is-active"><?= $e($t('settings.integrations.active.yes')) ?></span>
<?php else: ?>
<span class="status-pill"><?= $e($t('settings.integrations.active.no')) ?></span>
<?php endif; ?>
</td>
<td>
<div><?= $e($statusLabel) ?></div>
<?php if (!empty($item['last_test_at'])): ?>
<small class="muted"><?= $e((string) $item['last_test_at']) ?><?php if (($item['last_test_http_code'] ?? null) !== null): ?> | HTTP <?= $e((string) $item['last_test_http_code']) ?><?php endif; ?></small>
<?php endif; ?>
</td>
<td>
<a class="btn btn--secondary" href="/settings/integrations/shoppro?id=<?= $e((string) ($item['id'] ?? 0)) ?>"><?= $e($t('settings.integrations.actions.edit')) ?></a>
<form action="/settings/integrations/shoppro/test" method="post" style="display:inline-block; margin-left:6px;">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="integration_id" value="<?= $e((string) ($item['id'] ?? 0)) ?>">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.integrations.actions.test')) ?></button>
</form>
<form action="/settings/integrations/shoppro/import-offers-cache" method="post" style="display:inline-block; margin-left:6px;">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="integration_id" value="<?= $e((string) ($item['id'] ?? 0)) ?>">
<button type="submit" class="btn btn--secondary"><?= $e($t('settings.integrations.actions.import_offers_cache')) ?></button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section class="card mt-16">
<h2 class="section-title">
<?= $e($isEdit ? $t('settings.integrations.edit_title') : $t('settings.integrations.create_title')) ?>
</h2>
<form class="mt-16" action="/settings/integrations/shoppro/save" method="post">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="integration_id" value="<?= $e((string) ($formValues['integration_id'] ?? '0')) ?>">
<div class="form-grid">
<label class="form-field">
<span class="field-label"><?= $e($t('settings.integrations.fields.name')) ?></span>
<input class="form-control" type="text" name="name" value="<?= $e((string) ($formValues['name'] ?? '')) ?>" required>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.integrations.fields.base_url')) ?></span>
<input class="form-control" type="url" name="base_url" value="<?= $e((string) ($formValues['base_url'] ?? '')) ?>" placeholder="https://shoppro.project-dc.pl/" required>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.integrations.fields.api_key')) ?></span>
<input class="form-control" type="password" name="api_key" value="" placeholder="<?= $e($isEdit ? $t('settings.integrations.api_key_placeholder_edit') : '') ?>">
<?php if ($isEdit): ?>
<small class="muted">
<?= $e(($selected['has_api_key'] ?? false) ? $t('settings.integrations.api_key_saved') : $t('settings.integrations.api_key_missing')) ?>
</small>
<?php endif; ?>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.integrations.fields.timeout_seconds')) ?></span>
<input class="form-control" type="number" min="3" max="60" name="timeout_seconds" value="<?= $e((string) ($formValues['timeout_seconds'] ?? '10')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.integrations.fields.orders_fetch_start_date')) ?></span>
<input class="form-control" type="date" name="orders_fetch_start_date" value="<?= $e((string) ($formValues['orders_fetch_start_date'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.integrations.fields.order_status_sync_direction')) ?></span>
<select class="form-control" name="order_status_sync_direction">
<?php $syncDirection = (string) ($formValues['order_status_sync_direction'] ?? 'shoppro_to_orderpro'); ?>
<option value="shoppro_to_orderpro"<?= $syncDirection === 'shoppro_to_orderpro' ? ' selected' : '' ?>>
<?= $e($t('settings.integrations.fields.order_status_sync_direction_shoppro_to_orderpro')) ?>
</option>
<option value="orderpro_to_shoppro"<?= $syncDirection === 'orderpro_to_shoppro' ? ' selected' : '' ?>>
<?= $e($t('settings.integrations.fields.order_status_sync_direction_orderpro_to_shoppro')) ?>
</option>
</select>
</label>
</div>
<label class="form-field mt-12">
<span class="field-label">
<input type="checkbox" name="is_active" value="1"<?= ((string) ($formValues['is_active'] ?? '1')) === '1' ? ' checked' : '' ?>>
<?= $e($t('settings.integrations.fields.active_checkbox')) ?>
</span>
</label>
<label class="form-field mt-12">
<span class="field-label">
<input type="checkbox" name="orders_fetch_enabled" value="1"<?= ((string) ($formValues['orders_fetch_enabled'] ?? '0')) === '1' ? ' checked' : '' ?>>
<?= $e($t('settings.integrations.fields.orders_fetch_enabled_checkbox')) ?>
</span>
</label>
<div class="form-actions mt-16">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.integrations.actions.save')) ?></button>
<a href="/settings/integrations/shoppro" class="btn btn--secondary"><?= $e($t('settings.integrations.actions.new')) ?></a>
<?php if ($isEdit): ?>
<button
type="submit"
class="btn btn--secondary"
formaction="/settings/integrations/shoppro/test"
formmethod="post"
><?= $e($t('settings.integrations.actions.test_now')) ?></button>
<button
type="submit"
class="btn btn--secondary"
formaction="/settings/integrations/shoppro/import-offers-cache"
formmethod="post"
><?= $e($t('settings.integrations.actions.import_offers_cache')) ?></button>
<?php endif; ?>
</div>
</form>
<?php if ($selected !== null && !empty($tests)): ?>
<h3 class="section-title mt-16"><?= $e($t('settings.integrations.logs_title')) ?></h3>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th><?= $e($t('settings.integrations.logs.fields.tested_at')) ?></th>
<th><?= $e($t('settings.integrations.logs.fields.status')) ?></th>
<th><?= $e($t('settings.integrations.logs.fields.http_code')) ?></th>
<th><?= $e($t('settings.integrations.logs.fields.message')) ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($tests as $test): ?>
<?php $httpCode = $test['http_code'] ?? null; ?>
<tr>
<td><?= $e((string) ($test['tested_at'] ?? '')) ?></td>
<td><?= $e((string) ($test['status'] ?? '')) ?></td>
<td><?= $e($httpCode === null ? '-' : (string) $httpCode) ?></td>
<td><?= $e((string) ($test['message'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>

View File

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

View File

@@ -1,40 +0,0 @@
<section class="card">
<h1><?= $e($t('settings.title')) ?></h1>
<p class="muted"><?= $e($t('settings.description')) ?></p>
<nav class="settings-nav mt-16" aria-label="<?= $e($t('settings.submenu_label')) ?>">
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'database' ? ' is-active' : '' ?>" href="/settings/database"><?= $e($t('settings.database.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'order_statuses' ? ' is-active' : '' ?>" href="/settings/order-statuses"><?= $e($t('settings.order_statuses.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'cron' ? ' is-active' : '' ?>" href="/settings/cron"><?= $e($t('settings.cron.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'gs1' ? ' is-active' : '' ?>" href="/settings/gs1"><?= $e($t('settings.gs1.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'products' ? ' is-active' : '' ?>" href="/settings/products"><?= $e($t('settings.products.title')) ?></a>
</nav>
</section>
<section class="card mt-16">
<h2 class="section-title"><?= $e($t('settings.products.title')) ?></h2>
<p class="muted mt-12"><?= $e($t('settings.products.description')) ?></p>
<form action="/settings/products/save" method="post" class="mt-16">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<label class="form-field">
<span class="field-label"><?= $e($t('settings.products.fields.sku_format')) ?></span>
<input
class="form-control"
type="text"
name="products_sku_format"
maxlength="128"
value="<?= $e((string) ($productsSkuFormat ?? 'PP000000')) ?>"
placeholder="PP000000"
>
</label>
<p class="muted mt-12"><?= $e($t('settings.products.sku_format_hint')) ?></p>
<div class="form-actions mt-16">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.products.actions.save')) ?></button>
</div>
</form>
</section>

View File

@@ -1,200 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Cron;
use RuntimeException;
use Throwable;
final class CronJobProcessor
{
/** @var array<string, callable> */
private array $handlers = [];
public function __construct(private readonly CronJobRepository $jobs)
{
}
public function registerHandler(string $jobType, callable $handler): void
{
$normalized = trim($jobType);
if ($normalized === '') {
return;
}
$this->handlers[$normalized] = $handler;
}
/**
* @return array{created:int,skipped:int}
*/
public function createScheduledJobs(): array
{
$created = 0;
$skipped = 0;
$schedules = $this->jobs->getDueSchedules();
foreach ($schedules as $schedule) {
$scheduleId = (int) ($schedule['id'] ?? 0);
$jobType = trim((string) ($schedule['job_type'] ?? ''));
$intervalSeconds = max(60, (int) ($schedule['interval_seconds'] ?? 0));
if ($scheduleId <= 0 || $jobType === '') {
continue;
}
$hasPending = $this->jobs->hasPendingJob($jobType);
if ($hasPending) {
$skipped++;
} else {
$payload = is_array($schedule['payload'] ?? null) ? (array) $schedule['payload'] : null;
$this->jobs->enqueue(
$jobType,
$payload,
(int) ($schedule['priority'] ?? CronJobType::priorityFor($jobType)),
(int) ($schedule['max_attempts'] ?? CronJobType::maxAttemptsFor($jobType))
);
$created++;
}
$this->jobs->touchSchedule($scheduleId, $intervalSeconds);
}
return [
'created' => $created,
'skipped' => $skipped,
];
}
/**
* @return array{processed:int,completed:int,retried:int,failed:int}
*/
public function processQueue(int $limit = 20): array
{
$processed = 0;
$completed = 0;
$retried = 0;
$failed = 0;
$jobs = $this->jobs->fetchNext($limit);
foreach ($jobs as $job) {
$processed++;
$jobId = (int) ($job['id'] ?? 0);
$jobType = trim((string) ($job['job_type'] ?? ''));
if ($jobId <= 0 || $jobType === '') {
continue;
}
$handler = $this->handlers[$jobType] ?? null;
if (!is_callable($handler)) {
$defaultBackoff = $this->defaultBackoffSeconds((int) ($job['attempts'] ?? 0));
$isFinal = $this->jobs->markFailed(
$jobId,
'Brak zarejestrowanego handlera dla typu joba: ' . $jobType,
$defaultBackoff
);
if ($isFinal) {
$failed++;
} else {
$retried++;
}
continue;
}
try {
$payload = is_array($job['payload'] ?? null) ? (array) $job['payload'] : [];
$result = $handler($payload, $job);
$ok = true;
$message = '';
$retryAfter = 0;
$resultPayload = [];
if (is_bool($result)) {
$ok = $result;
} elseif (is_array($result)) {
$ok = ($result['ok'] ?? true) === true;
$message = trim((string) ($result['message'] ?? ''));
$retryAfter = max(0, (int) ($result['retry_after'] ?? 0));
$resultPayload = $result;
}
if ($ok) {
$this->jobs->markCompleted($jobId, $resultPayload === [] ? null : $resultPayload);
$completed++;
continue;
}
if ($message === '') {
$message = 'Handler zakonczyl job niepowodzeniem.';
}
$backoffSeconds = $retryAfter > 0 ? $retryAfter : $this->defaultBackoffSeconds((int) ($job['attempts'] ?? 0));
$isFinal = $this->jobs->markFailed($jobId, $message, $backoffSeconds);
if ($isFinal) {
$failed++;
} else {
$retried++;
}
} catch (Throwable $exception) {
$backoffSeconds = $this->defaultBackoffSeconds((int) ($job['attempts'] ?? 0));
$isFinal = $this->jobs->markFailed($jobId, $exception->getMessage(), $backoffSeconds);
if ($isFinal) {
$failed++;
} else {
$retried++;
}
}
}
return [
'processed' => $processed,
'completed' => $completed,
'retried' => $retried,
'failed' => $failed,
];
}
/**
* @return array{
* recovered:int,
* scheduled_created:int,
* scheduled_skipped:int,
* processed:int,
* completed:int,
* retried:int,
* failed:int,
* cleaned:int
* }
*/
public function run(int $limit = 20): array
{
if ($limit <= 0) {
throw new RuntimeException('Limit przetwarzania cron musi byc wiekszy od 0.');
}
$recovered = $this->jobs->recoverStuck(15);
$scheduled = $this->createScheduledJobs();
$processed = $this->processQueue($limit);
$cleaned = $this->jobs->cleanup(30);
return [
'recovered' => $recovered,
'scheduled_created' => (int) ($scheduled['created'] ?? 0),
'scheduled_skipped' => (int) ($scheduled['skipped'] ?? 0),
'processed' => (int) ($processed['processed'] ?? 0),
'completed' => (int) ($processed['completed'] ?? 0),
'retried' => (int) ($processed['retried'] ?? 0),
'failed' => (int) ($processed['failed'] ?? 0),
'cleaned' => $cleaned,
];
}
private function defaultBackoffSeconds(int $attemptsAlreadyDone): int
{
$currentAttempt = max(1, $attemptsAlreadyDone + 1);
$seconds = (int) (60 * (2 ** ($currentAttempt - 1)));
return min(3600, max(60, $seconds));
}
}

View File

@@ -1,517 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Cron;
use DateTimeImmutable;
use PDO;
use Throwable;
final class CronJobRepository
{
public function __construct(private readonly PDO $pdo)
{
}
public function enqueue(
string $jobType,
?array $payload = null,
?int $priority = null,
?int $maxAttempts = null,
?string $scheduledAt = null
): int {
$statement = $this->pdo->prepare(
'INSERT INTO cron_jobs (
job_type, status, priority, payload, attempts, max_attempts,
scheduled_at, created_at, updated_at
) VALUES (
:job_type, :status, :priority, :payload, :attempts, :max_attempts,
:scheduled_at, :created_at, :updated_at
)'
);
$now = date('Y-m-d H:i:s');
$scheduled = $scheduledAt !== null && trim($scheduledAt) !== ''
? trim($scheduledAt)
: $now;
$resolvedPriority = $priority !== null && $priority >= 0
? min(255, $priority)
: CronJobType::priorityFor($jobType);
$resolvedMaxAttempts = $maxAttempts !== null && $maxAttempts > 0
? min(999, $maxAttempts)
: CronJobType::maxAttemptsFor($jobType);
$statement->execute([
'job_type' => trim($jobType),
'status' => 'pending',
'priority' => $resolvedPriority,
'payload' => $this->encodeJson($payload),
'attempts' => 0,
'max_attempts' => $resolvedMaxAttempts,
'scheduled_at' => $scheduled,
'created_at' => $now,
'updated_at' => $now,
]);
return (int) $this->pdo->lastInsertId();
}
public function hasPendingJob(string $jobType, ?array $payload = null): bool
{
$sql = 'SELECT 1
FROM cron_jobs
WHERE job_type = :job_type
AND status IN (\'pending\', \'processing\')';
$params = [
'job_type' => trim($jobType),
];
if ($payload !== null) {
$sql .= ' AND payload = :payload';
$params['payload'] = $this->encodeJson($payload);
}
$sql .= ' LIMIT 1';
$statement = $this->pdo->prepare($sql);
$statement->execute($params);
return $statement->fetchColumn() !== false;
}
/**
* @return array<int, array<string, mixed>>
*/
public function fetchNext(int $limit = 1): array
{
$safeLimit = max(1, min(100, $limit));
$now = date('Y-m-d H:i:s');
$this->pdo->beginTransaction();
try {
$select = $this->pdo->prepare(
'SELECT id, job_type, status, priority, payload, result, attempts, max_attempts,
last_error, scheduled_at, started_at, completed_at, created_at, updated_at
FROM cron_jobs
WHERE status = :status
AND scheduled_at <= :scheduled_at
ORDER BY priority ASC, scheduled_at ASC, id ASC
LIMIT :limit
FOR UPDATE'
);
$select->bindValue(':status', 'pending');
$select->bindValue(':scheduled_at', $now);
$select->bindValue(':limit', $safeLimit, PDO::PARAM_INT);
$select->execute();
$rows = $select->fetchAll();
if (!is_array($rows) || $rows === []) {
if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
return [];
}
$ids = array_values(array_map(
static fn (array $row): int => (int) ($row['id'] ?? 0),
array_filter($rows, static fn (mixed $row): bool => is_array($row))
));
$ids = array_values(array_filter($ids, static fn (int $id): bool => $id > 0));
if ($ids === []) {
if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
return [];
}
$placeholders = implode(', ', array_fill(0, count($ids), '?'));
$update = $this->pdo->prepare(
'UPDATE cron_jobs SET
status = ?,
started_at = ?,
updated_at = ?
WHERE id IN (' . $placeholders . ')'
);
$update->execute(array_merge(['processing', $now, $now], $ids));
if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
return array_map([$this, 'mapJobRow'], $rows);
} catch (Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $exception;
}
}
public function markCompleted(int $jobId, ?array $result = null): void
{
$statement = $this->pdo->prepare(
'UPDATE cron_jobs SET
status = :status,
attempts = attempts + 1,
result = :result,
last_error = NULL,
completed_at = :completed_at,
updated_at = :updated_at
WHERE id = :id'
);
$now = date('Y-m-d H:i:s');
$statement->execute([
'id' => $jobId,
'status' => 'completed',
'result' => $this->encodeJson($result),
'completed_at' => $now,
'updated_at' => $now,
]);
}
public function markFailed(int $jobId, string $errorMessage, int $backoffSeconds = 60): bool
{
$this->pdo->beginTransaction();
try {
$select = $this->pdo->prepare(
'SELECT attempts, max_attempts
FROM cron_jobs
WHERE id = :id
LIMIT 1
FOR UPDATE'
);
$select->execute(['id' => $jobId]);
$row = $select->fetch();
if (!is_array($row)) {
if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
return true;
}
$attempts = (int) ($row['attempts'] ?? 0) + 1;
$maxAttempts = max(1, (int) ($row['max_attempts'] ?? 1));
$trimmedError = mb_substr(trim($errorMessage), 0, 500);
$now = date('Y-m-d H:i:s');
if ($attempts >= $maxAttempts) {
$update = $this->pdo->prepare(
'UPDATE cron_jobs SET
status = :status,
attempts = :attempts,
last_error = :last_error,
completed_at = :completed_at,
updated_at = :updated_at
WHERE id = :id'
);
$update->execute([
'id' => $jobId,
'status' => 'failed',
'attempts' => $attempts,
'last_error' => $trimmedError,
'completed_at' => $now,
'updated_at' => $now,
]);
if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
return true;
}
$scheduledAt = (new DateTimeImmutable($now))
->modify('+' . max(1, $backoffSeconds) . ' seconds')
->format('Y-m-d H:i:s');
$update = $this->pdo->prepare(
'UPDATE cron_jobs SET
status = :status,
attempts = :attempts,
last_error = :last_error,
scheduled_at = :scheduled_at,
started_at = NULL,
completed_at = NULL,
updated_at = :updated_at
WHERE id = :id'
);
$update->execute([
'id' => $jobId,
'status' => 'pending',
'attempts' => $attempts,
'last_error' => $trimmedError,
'scheduled_at' => $scheduledAt,
'updated_at' => $now,
]);
if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
return false;
} catch (Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $exception;
}
}
public function recoverStuck(int $olderThanMinutes = 15): int
{
$threshold = (new DateTimeImmutable())
->modify('-' . max(1, $olderThanMinutes) . ' minutes')
->format('Y-m-d H:i:s');
$now = date('Y-m-d H:i:s');
$statement = $this->pdo->prepare(
'UPDATE cron_jobs SET
status = :status,
started_at = NULL,
scheduled_at = :scheduled_at,
updated_at = :updated_at
WHERE status = :processing_status
AND started_at IS NOT NULL
AND started_at < :threshold'
);
$statement->execute([
'status' => 'pending',
'processing_status' => 'processing',
'scheduled_at' => $now,
'updated_at' => $now,
'threshold' => $threshold,
]);
return $statement->rowCount();
}
public function cleanup(int $olderThanDays = 30): int
{
$threshold = (new DateTimeImmutable())
->modify('-' . max(1, $olderThanDays) . ' days')
->format('Y-m-d H:i:s');
$statement = $this->pdo->prepare(
'DELETE FROM cron_jobs
WHERE status IN (\'completed\', \'failed\', \'cancelled\')
AND completed_at IS NOT NULL
AND completed_at < :threshold'
);
$statement->execute(['threshold' => $threshold]);
return $statement->rowCount();
}
/**
* @return array<int, array<string, mixed>>
*/
public function getDueSchedules(): array
{
$statement = $this->pdo->prepare(
'SELECT id, job_type, interval_seconds, priority, max_attempts, payload,
enabled, last_run_at, next_run_at, created_at, updated_at
FROM cron_schedules
WHERE enabled = 1
AND (next_run_at IS NULL OR next_run_at <= :now)
ORDER BY priority ASC, next_run_at ASC, id ASC'
);
$statement->execute(['now' => date('Y-m-d H:i:s')]);
$rows = $statement->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map([$this, 'mapScheduleRow'], $rows);
}
public function touchSchedule(int $scheduleId, int $intervalSeconds): void
{
$safeInterval = max(60, $intervalSeconds);
$now = date('Y-m-d H:i:s');
$nextRunAt = (new DateTimeImmutable($now))
->modify('+' . $safeInterval . ' seconds')
->format('Y-m-d H:i:s');
$statement = $this->pdo->prepare(
'UPDATE cron_schedules SET
last_run_at = :last_run_at,
next_run_at = :next_run_at,
updated_at = :updated_at
WHERE id = :id'
);
$statement->execute([
'id' => $scheduleId,
'last_run_at' => $now,
'next_run_at' => $nextRunAt,
'updated_at' => $now,
]);
}
/**
* @return array<int, array<string, mixed>>
*/
public function listPastJobs(int $limit = 100): array
{
$statement = $this->pdo->prepare(
'SELECT id, job_type, status, priority, payload, result, attempts, max_attempts,
last_error, scheduled_at, started_at, completed_at, created_at, updated_at
FROM cron_jobs
WHERE scheduled_at <= :now
ORDER BY scheduled_at DESC, id DESC
LIMIT :limit'
);
$statement->bindValue(':now', date('Y-m-d H:i:s'));
$statement->bindValue(':limit', max(1, min(500, $limit)), PDO::PARAM_INT);
$statement->execute();
$rows = $statement->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map([$this, 'mapJobRow'], $rows);
}
/**
* @return array<int, array<string, mixed>>
*/
public function listFutureJobs(int $limit = 100): array
{
$statement = $this->pdo->prepare(
'SELECT id, job_type, status, priority, payload, result, attempts, max_attempts,
last_error, scheduled_at, started_at, completed_at, created_at, updated_at
FROM cron_jobs
WHERE scheduled_at > :now
ORDER BY scheduled_at ASC, priority ASC, id ASC
LIMIT :limit'
);
$statement->bindValue(':now', date('Y-m-d H:i:s'));
$statement->bindValue(':limit', max(1, min(500, $limit)), PDO::PARAM_INT);
$statement->execute();
$rows = $statement->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map([$this, 'mapJobRow'], $rows);
}
/**
* @return array<int, array<string, mixed>>
*/
public function listSchedules(int $limit = 100): array
{
$statement = $this->pdo->prepare(
'SELECT id, job_type, interval_seconds, priority, max_attempts, payload,
enabled, last_run_at, next_run_at, created_at, updated_at
FROM cron_schedules
ORDER BY priority ASC, job_type ASC
LIMIT :limit'
);
$statement->bindValue(':limit', max(1, min(500, $limit)), PDO::PARAM_INT);
$statement->execute();
$rows = $statement->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map([$this, 'mapScheduleRow'], $rows);
}
/**
* @param array<string, mixed>|null $payload
*/
private function encodeJson(?array $payload): ?string
{
if ($payload === null) {
return null;
}
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
return null;
}
return $encoded;
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function mapJobRow(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'job_type' => (string) ($row['job_type'] ?? ''),
'status' => (string) ($row['status'] ?? ''),
'priority' => (int) ($row['priority'] ?? 100),
'payload' => $this->decodeJson($row['payload'] ?? null),
'result' => $this->decodeJson($row['result'] ?? null),
'attempts' => (int) ($row['attempts'] ?? 0),
'max_attempts' => (int) ($row['max_attempts'] ?? 0),
'last_error' => isset($row['last_error']) ? (string) $row['last_error'] : null,
'scheduled_at' => isset($row['scheduled_at']) ? (string) $row['scheduled_at'] : null,
'started_at' => isset($row['started_at']) ? (string) $row['started_at'] : null,
'completed_at' => isset($row['completed_at']) ? (string) $row['completed_at'] : null,
'created_at' => isset($row['created_at']) ? (string) $row['created_at'] : null,
'updated_at' => isset($row['updated_at']) ? (string) $row['updated_at'] : null,
];
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function mapScheduleRow(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'job_type' => (string) ($row['job_type'] ?? ''),
'interval_seconds' => (int) ($row['interval_seconds'] ?? 0),
'priority' => (int) ($row['priority'] ?? 100),
'max_attempts' => (int) ($row['max_attempts'] ?? 3),
'payload' => $this->decodeJson($row['payload'] ?? null),
'enabled' => ((int) ($row['enabled'] ?? 0)) === 1,
'last_run_at' => isset($row['last_run_at']) ? (string) $row['last_run_at'] : null,
'next_run_at' => isset($row['next_run_at']) ? (string) $row['next_run_at'] : null,
'created_at' => isset($row['created_at']) ? (string) $row['created_at'] : null,
'updated_at' => isset($row['updated_at']) ? (string) $row['updated_at'] : null,
];
}
/**
* @return array<string, mixed>|null
*/
private function decodeJson(mixed $value): ?array
{
if ($value === null) {
return null;
}
$raw = trim((string) $value);
if ($raw === '') {
return null;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return null;
}
return $decoded;
}
}

View File

@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Cron;
final class CronJobType
{
public const PRODUCT_LINKS_HEALTH_CHECK = 'product_links_health_check';
public const SHOPPRO_OFFER_TITLES_REFRESH = 'shoppro_offer_titles_refresh';
public const SHOPPRO_ORDERS_IMPORT = 'shoppro_orders_import';
public const SHOPPRO_ORDER_STATUS_SYNC = 'shoppro_order_status_sync';
public const PRIORITY_HIGH = 50;
public const PRIORITY_NORMAL = 100;
public const PRIORITY_LOW = 200;
public static function priorityFor(string $jobType): int
{
return match (trim($jobType)) {
self::PRODUCT_LINKS_HEALTH_CHECK => 110,
self::SHOPPRO_ORDERS_IMPORT => 90,
self::SHOPPRO_ORDER_STATUS_SYNC => 95,
self::SHOPPRO_OFFER_TITLES_REFRESH => 170,
default => self::PRIORITY_NORMAL,
};
}
public static function maxAttemptsFor(string $jobType): int
{
return match (trim($jobType)) {
self::PRODUCT_LINKS_HEALTH_CHECK => 3,
self::SHOPPRO_ORDERS_IMPORT => 3,
self::SHOPPRO_ORDER_STATUS_SYNC => 3,
self::SHOPPRO_OFFER_TITLES_REFRESH => 3,
default => 3,
};
}
}

View File

@@ -1,140 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Cron;
use App\Modules\ProductLinks\ChannelOffersRepository;
use App\Modules\ProductLinks\OfferImportService;
use App\Modules\ProductLinks\ProductLinksRepository;
use App\Modules\Settings\IntegrationRepository;
use Throwable;
final class ProductLinksHealthCheckHandler
{
private const ALERT_TYPE = 'missing_remote_link';
private const ALERT_MESSAGE = 'Powiazanie nie istnieje juz po stronie zewnetrznej.';
public function __construct(
private readonly IntegrationRepository $integrations,
private readonly OfferImportService $offerImportService,
private readonly ProductLinksRepository $links,
private readonly ChannelOffersRepository $offers
) {
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $job
* @return array<string, mixed>
*/
public function __invoke(array $payload = [], array $job = []): array
{
$forcedIntegrationId = max(0, (int) ($payload['integration_id'] ?? 0));
$activeIntegrations = array_values(array_filter(
$this->integrations->listByType('shoppro'),
static function (array $integration) use ($forcedIntegrationId): bool {
$id = (int) ($integration['id'] ?? 0);
if ($forcedIntegrationId > 0 && $id !== $forcedIntegrationId) {
return false;
}
return $id > 0
&& ($integration['is_active'] ?? false) === true
&& ($integration['has_api_key'] ?? false) === true;
}
));
if ($activeIntegrations === []) {
return [
'ok' => true,
'message' => 'Brak aktywnych integracji z kluczem API do weryfikacji powiazan.',
'checked_links' => 0,
'missing_links' => 0,
'integrations' => 0,
'integration_failures' => 0,
];
}
$checkedLinks = 0;
$missingLinks = 0;
$resolvedAlerts = 0;
$integrationFailures = 0;
$errors = [];
$checkedAt = date('Y-m-d H:i:s');
foreach ($activeIntegrations as $integration) {
$integrationId = (int) ($integration['id'] ?? 0);
if ($integrationId <= 0) {
continue;
}
try {
$credentials = $this->integrations->findApiCredentials($integrationId);
} catch (Throwable $exception) {
$integrationFailures++;
if (count($errors) < 5) {
$errors[] = 'Integracja #' . $integrationId . ': ' . $exception->getMessage();
}
continue;
}
if ($credentials === null || trim((string) ($credentials['api_key'] ?? '')) === '') {
$integrationFailures++;
if (count($errors) < 5) {
$errors[] = 'Integracja #' . $integrationId . ': brak poprawnych danych API.';
}
continue;
}
$import = $this->offerImportService->importShopProOffers($credentials);
if (($import['ok'] ?? false) !== true) {
$integrationFailures++;
if (count($errors) < 5) {
$errors[] = 'Integracja #' . $integrationId . ': ' . trim((string) ($import['message'] ?? 'Blad importu ofert.'));
}
continue;
}
$links = $this->links->listActiveLinksForMissingCheck($integrationId);
foreach ($links as $link) {
$mapId = (int) ($link['id'] ?? 0);
$externalProductId = trim((string) ($link['external_product_id'] ?? ''));
$externalVariantId = $this->nullableText($link['external_variant_id'] ?? null);
if ($mapId <= 0 || $externalProductId === '') {
continue;
}
$checkedLinks++;
$offer = $this->offers->findByExternalIdentity($integrationId, $externalProductId, $externalVariantId);
if ($offer === null) {
$missingLinks++;
$this->links->upsertActiveAlert($mapId, self::ALERT_TYPE, self::ALERT_MESSAGE, $checkedAt);
continue;
}
$this->links->resolveActiveAlert($mapId, self::ALERT_TYPE, $checkedAt);
$resolvedAlerts++;
}
}
return [
'ok' => $integrationFailures === 0,
'message' => $integrationFailures === 0
? 'Weryfikacja powiazan zakonczona.'
: 'Weryfikacja zakonczona z bledami integracji.',
'checked_links' => $checkedLinks,
'missing_links' => $missingLinks,
'resolved_alerts' => $resolvedAlerts,
'integrations' => count($activeIntegrations),
'integration_failures' => $integrationFailures,
'errors' => $errors,
];
}
private function nullableText(mixed $value): ?string
{
$text = trim((string) $value);
return $text === '' ? null : $text;
}
}

View File

@@ -1,106 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Cron;
use App\Modules\ProductLinks\OfferImportService;
use App\Modules\Settings\IntegrationRepository;
use Throwable;
final class ShopProOfferTitlesRefreshHandler
{
public function __construct(
private readonly IntegrationRepository $integrations,
private readonly OfferImportService $offerImportService
) {
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $job
* @return array<string, mixed>
*/
public function __invoke(array $payload = [], array $job = []): array
{
$forcedIntegrationId = max(0, (int) ($payload['integration_id'] ?? 0));
$activeIntegrations = array_values(array_filter(
$this->integrations->listByType('shoppro'),
static function (array $integration) use ($forcedIntegrationId): bool {
$id = (int) ($integration['id'] ?? 0);
if ($forcedIntegrationId > 0 && $id !== $forcedIntegrationId) {
return false;
}
return $id > 0
&& ($integration['is_active'] ?? false) === true
&& ($integration['has_api_key'] ?? false) === true;
}
));
if ($activeIntegrations === []) {
return [
'ok' => true,
'message' => 'Brak aktywnych integracji z kluczem API do odswiezenia tytulow ofert.',
'integrations' => 0,
'updated_offers' => 0,
'failed_offers' => 0,
'integration_failures' => 0,
'errors' => [],
];
}
$updatedOffers = 0;
$failedOffers = 0;
$integrationFailures = 0;
$errors = [];
foreach ($activeIntegrations as $integration) {
$integrationId = (int) ($integration['id'] ?? 0);
if ($integrationId <= 0) {
continue;
}
try {
$credentials = $this->integrations->findApiCredentials($integrationId);
} catch (Throwable $exception) {
$integrationFailures++;
if (count($errors) < 5) {
$errors[] = 'Integracja #' . $integrationId . ': ' . $exception->getMessage();
}
continue;
}
if ($credentials === null || trim((string) ($credentials['api_key'] ?? '')) === '') {
$integrationFailures++;
if (count($errors) < 5) {
$errors[] = 'Integracja #' . $integrationId . ': brak poprawnych danych API.';
}
continue;
}
$import = $this->offerImportService->importShopProOffers($credentials);
if (($import['ok'] ?? false) !== true) {
$integrationFailures++;
if (count($errors) < 5) {
$errors[] = 'Integracja #' . $integrationId . ': ' . trim((string) ($import['message'] ?? 'Blad importu ofert.'));
}
continue;
}
$updatedOffers += (int) ($import['imported'] ?? 0);
$failedOffers += (int) ($import['failed'] ?? 0);
}
return [
'ok' => $integrationFailures === 0,
'message' => $integrationFailures === 0
? 'Odswiezenie tytulow ofert zakonczone.'
: 'Odswiezenie tytulow zakonczone z bledami integracji.',
'integrations' => count($activeIntegrations),
'updated_offers' => $updatedOffers,
'failed_offers' => $failedOffers,
'integration_failures' => $integrationFailures,
'errors' => $errors,
];
}
}

View File

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

View File

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

View File

@@ -1,69 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\GS1;
use App\Modules\Products\ProductRepository;
use App\Modules\Settings\AppSettingsRepository;
class GS1Service
{
private ProductRepository $products;
private AppSettingsRepository $appSettings;
public function __construct(ProductRepository $products, AppSettingsRepository $appSettings)
{
$this->products = $products;
$this->appSettings = $appSettings;
}
/**
* @return array{ean: string}
* @throws \RuntimeException
*/
public function assignEanToProduct(int $productId): array
{
$product = $this->products->findById($productId, 'pl');
if ($product === null) {
throw new \RuntimeException('Produkt nie istnieje.');
}
$existingEan = trim((string) ($product['ean'] ?? ''));
if ($existingEan !== '') {
throw new \RuntimeException('Produkt ma juz przypisany EAN: ' . $existingEan);
}
$login = $this->appSettings->get('gs1_api_login', '');
$password = $this->appSettings->get('gs1_api_password', '');
$prefix = $this->appSettings->get('gs1_prefix', '590532390');
$defaultBrand = $this->appSettings->get('gs1_default_brand', 'pomysloweprezenty.pl');
$defaultGpcCode = $this->appSettings->getInt('gs1_default_gpc_code', 10008365);
if ($login === '' || $password === '') {
throw new \RuntimeException('Brak danych dostepu do API GS1. Uzupelnij je w Ustawienia > GS1.');
}
$client = new MojeGS1Client($login, $password);
$highest = $client->findHighestGtin($prefix);
$newEan = MojeGS1Client::generateNextEan($prefix, $highest);
$productName = trim((string) ($product['name'] ?? ''));
$commonName = $productName !== '' ? mb_substr($productName, 0, 150) : 'Produkt ' . $productId;
$client->upsertProduct($newEan, [
'brandName' => $defaultBrand,
'commonName' => $commonName,
'gpcCode' => $defaultGpcCode,
'netContent' => 1,
'netContentUnit' => 'szt',
'status' => 'ACT',
'targetMarket' => ['PL'],
'descriptionLanguage' => 'PL',
]);
$this->products->updateEan($productId, $newEan);
return ['ean' => $newEan];
}
}

View File

@@ -1,211 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\GS1;
class MojeGS1Client
{
private const BASE_URL = 'https://mojegs1.pl/api/v2';
private const TIMEOUT = 30;
private string $login;
private string $password;
public function __construct(string $login, string $password)
{
$this->login = $login;
$this->password = $password;
}
/**
* @return array{data: array<int, array<string, mixed>>, total: int}
*/
public function listProducts(int $offset = 1, int $limit = 100): array
{
$url = self::BASE_URL . '/products?page[offset]=' . max(1, $offset) . '&page[limit]=' . $limit . '&sort=name';
$response = $this->request('GET', $url);
$data = $response['data'] ?? [];
$total = (int) ($response['meta']['record-count'] ?? 0);
return ['data' => is_array($data) ? $data : [], 'total' => $total];
}
/**
* @return array<string, mixed>|null
*/
public function getProduct(string $gtin): ?array
{
$url = self::BASE_URL . '/products/' . urlencode($gtin);
try {
$response = $this->request('GET', $url);
} catch (\RuntimeException $e) {
if (str_contains($e->getMessage(), '404')) {
return null;
}
throw $e;
}
return is_array($response['data'] ?? null) ? $response['data'] : null;
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function upsertProduct(string $gtin, array $attributes): array
{
$url = self::BASE_URL . '/products/' . urlencode($gtin);
$payload = [
'data' => [
'type' => 'products',
'id' => $gtin,
'attributes' => $attributes,
],
];
return $this->request('PUT', $url, $payload);
}
/**
* Finds the highest GTIN registered under the given prefix by paginating through all products.
*/
public function findHighestGtin(string $prefix): ?string
{
$highest = null;
$page = 1;
$limit = 100;
do {
$result = $this->listProducts($page, $limit);
$items = $result['data'];
foreach ($items as $item) {
$gtin = (string) ($item['id'] ?? '');
if ($gtin === '' || !str_starts_with($gtin, $prefix)) {
continue;
}
if ($highest === null || $gtin > $highest) {
$highest = $gtin;
}
}
$page++;
} while (count($items) >= $limit);
return $highest;
}
/**
* Generates the next EAN-13 from a prefix, given the current highest GTIN.
*/
public static function generateNextEan(string $prefix, ?string $currentHighest): string
{
$prefixLen = strlen($prefix);
$itemDigits = 12 - $prefixLen;
if ($currentHighest !== null && str_starts_with($currentHighest, $prefix)) {
$currentItem = (int) substr($currentHighest, $prefixLen, $itemDigits);
$nextItem = $currentItem + 1;
} else {
$nextItem = 0;
}
$partial12 = $prefix . str_pad((string) $nextItem, $itemDigits, '0', STR_PAD_LEFT);
$checkDigit = self::calculateEan13CheckDigit($partial12);
return $partial12 . $checkDigit;
}
/**
* Calculates the EAN-13 check digit for the first 12 digits.
*/
public static function calculateEan13CheckDigit(string $partial12): int
{
if (strlen($partial12) !== 12 || !ctype_digit($partial12)) {
throw new \InvalidArgumentException('EAN-13 check digit requires exactly 12 digits, got: ' . $partial12);
}
$sum = 0;
for ($i = 0; $i < 12; $i++) {
$digit = (int) $partial12[$i];
$sum += ($i % 2 === 0) ? $digit : $digit * 3;
}
$remainder = $sum % 10;
return $remainder === 0 ? 0 : 10 - $remainder;
}
/**
* @param array<string, mixed>|null $jsonBody
* @return array<string, mixed>
*/
private function request(string $method, string $url, ?array $jsonBody = null): array
{
$curl = curl_init($url);
if ($curl === false) {
throw new \RuntimeException('Nie mozna zainicjalizowac cURL.');
}
$headers = [
'Accept: application/json',
];
$requestBody = null;
if ($jsonBody !== null && $method !== 'GET') {
$encoded = json_encode($jsonBody, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
throw new \RuntimeException('Nie mozna zakodowac payload JSON.');
}
$requestBody = $encoded;
$headers[] = 'Content-Type: application/json';
}
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_USERPWD => $this->login . ':' . $this->password,
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
CURLOPT_TIMEOUT => self::TIMEOUT,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_CUSTOMREQUEST => $method,
]);
if ($requestBody !== null) {
curl_setopt($curl, CURLOPT_POSTFIELDS, $requestBody);
}
$body = curl_exec($curl);
$httpCode = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
if ($error !== '') {
throw new \RuntimeException('GS1 API cURL error: ' . $error);
}
$bodyStr = is_string($body) ? $body : '';
if ($httpCode < 200 || $httpCode >= 300) {
$debug = ' | ' . $method . ' ' . $url;
if ($requestBody !== null) {
$debug .= ' | REQ: ' . mb_substr($requestBody, 0, 600);
}
throw new \RuntimeException(
'GS1 API HTTP ' . $httpCode . ': ' . mb_substr($bodyStr, 0, 400) . $debug
);
}
$decoded = json_decode($bodyStr, true);
if (!is_array($decoded)) {
throw new \RuntimeException('GS1 API: nieprawidlowa odpowiedz JSON.');
}
return $decoded;
}
}

View File

@@ -1,629 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Marketplace;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use App\Modules\Products\ProductRepository;
use App\Modules\Products\ProductService;
use App\Modules\Products\ProductValidator;
use App\Modules\Settings\IntegrationRepository;
use App\Modules\Settings\ShopProClient;
final class MarketplaceController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly MarketplaceRepository $marketplace,
private readonly IntegrationRepository $integrationRepository,
private readonly ShopProClient $shopProClient,
private readonly ProductRepository $productRepository,
private readonly ProductService $productService,
private readonly ProductValidator $productValidator
) {
}
public function index(Request $request): Response
{
$integrations = $this->marketplace->listActiveIntegrationsWithCounts();
$html = $this->template->render('marketplace/index', [
'title' => $this->translator->get('marketplace.title'),
'activeMenu' => 'marketplace',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'selectedMarketplaceIntegrationId' => 0,
'marketplaceIntegrations' => $integrations,
'integrations' => $integrations,
'errorMessage' => (string) Flash::get('marketplace_error', ''),
], 'layouts/app');
return Response::html($html);
}
public function offers(Request $request): Response
{
$integrationId = max(0, (int) $request->input('integration_id', 0));
if ($integrationId <= 0) {
Flash::set('marketplace_error', $this->translator->get('marketplace.flash.integration_not_found'));
return Response::redirect('/marketplace');
}
$integration = $this->marketplace->findActiveIntegrationById($integrationId);
if ($integration === null) {
Flash::set('marketplace_error', $this->translator->get('marketplace.flash.integration_not_found'));
return Response::redirect('/marketplace');
}
$integrations = $this->marketplace->listActiveIntegrationsWithCounts();
$filtersValues = [
'search' => trim((string) $request->input('search', '')),
'channel' => trim((string) $request->input('channel', '')),
'sort' => (string) $request->input('sort', 'updated_at'),
'sort_dir' => (string) $request->input('sort_dir', 'DESC'),
'page' => max(1, (int) $request->input('page', 1)),
'per_page' => max(1, min(100, (int) $request->input('per_page', 20))),
];
$offersResult = $this->marketplace->paginateLinkedOffersByIntegration($integrationId, $filtersValues, 'pl');
$offers = (array) ($offersResult['items'] ?? []);
$totalPages = max(1, (int) ceil(((int) ($offersResult['total'] ?? 0)) / (int) ($offersResult['per_page'] ?? 20)));
$channelOptions = $this->marketplace->listOfferChannelsByIntegration($integrationId);
$html = $this->template->render('marketplace/offers', [
'title' => $this->translator->get('marketplace.offers_title', ['name' => (string) ($integration['name'] ?? '')]),
'activeMenu' => 'marketplace',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'selectedMarketplaceIntegrationId' => $integrationId,
'marketplaceIntegrations' => $integrations,
'integration' => $integration,
'offers' => $offers,
'filters' => $filtersValues,
'channelOptions' => $channelOptions,
'pagination' => [
'page' => (int) ($offersResult['page'] ?? 1),
'total_pages' => $totalPages,
'total' => (int) ($offersResult['total'] ?? 0),
'per_page' => (int) ($offersResult['per_page'] ?? 20),
],
'errorMessage' => (string) Flash::get('marketplace_error', ''),
'successMessage' => (string) Flash::get('marketplace_success', ''),
], 'layouts/app');
return Response::html($html);
}
public function editProduct(Request $request): Response
{
$integrationId = max(0, (int) $request->input('integration_id', 0));
$externalProductId = max(0, (int) $request->input('external_product_id', 0));
if ($integrationId <= 0 || $externalProductId <= 0) {
Flash::set('marketplace_error', $this->translator->get('marketplace.flash.integration_not_found'));
return Response::redirect('/marketplace');
}
$integration = $this->marketplace->findActiveIntegrationById($integrationId);
if ($integration === null) {
Flash::set('marketplace_error', $this->translator->get('marketplace.flash.integration_not_found'));
return Response::redirect('/marketplace');
}
$credentials = $this->integrationRepository->findApiCredentials($integrationId);
if ($credentials === null) {
Flash::set('marketplace_error', $this->translator->get('marketplace.flash.integration_not_found'));
return Response::redirect('/marketplace/' . $integrationId);
}
$localProductId = $this->integrationRepository->findMappedProductId(
'shoppro',
(string) $externalProductId,
$integrationId
);
if ($localProductId === null || $localProductId <= 0) {
Flash::set('marketplace_error', $this->translator->get('products.flash.not_found'));
return Response::redirect('/marketplace/' . $integrationId);
}
$remoteResult = $this->shopProClient->fetchProductById(
(string) ($credentials['base_url'] ?? ''),
(string) ($credentials['api_key'] ?? ''),
(int) ($credentials['timeout_seconds'] ?? 10),
$externalProductId
);
if (($remoteResult['ok'] ?? false) !== true) {
Flash::set('marketplace_error', (string) ($remoteResult['message'] ?? 'Nie mozna pobrac produktu z integracji.'));
return Response::redirect('/marketplace/' . $integrationId);
}
$externalProduct = is_array($remoteResult['product'] ?? null) ? $remoteResult['product'] : null;
if ($externalProduct === null) {
Flash::set('marketplace_error', 'Brak danych produktu z integracji.');
return Response::redirect('/marketplace/' . $integrationId);
}
$form = $this->mapExternalProductToForm($externalProduct, $externalProductId);
$old = (array) Flash::get('products_form_old', []);
if ($old !== []) {
$form = array_merge($form, $old);
}
$activeIntegrations = $this->integrationRepository->listByType('shoppro');
$integrationTranslationsMap = [];
foreach ($this->productRepository->findIntegrationTranslations($localProductId) as $row) {
$integrationTranslationsMap[(int) ($row['integration_id'] ?? 0)] = $row;
}
$lang = $this->resolveProductLanguage($externalProduct);
$integrationTranslationsMap[$integrationId] = [
'integration_id' => $integrationId,
'name' => trim((string) ($lang['name'] ?? '')),
'short_description' => trim((string) ($lang['short_description'] ?? '')),
'description' => trim((string) ($lang['description'] ?? '')),
];
$html = $this->template->render('products/edit', [
'title' => 'Edycja produktu z integracji #' . $externalProductId,
'activeMenu' => 'marketplace',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'marketplaceIntegrations' => $this->marketplace->listActiveIntegrationsWithCounts(),
'productId' => $localProductId,
'form' => $form,
'productImages' => [],
'errors' => (array) Flash::get('products_form_errors', []),
'activeIntegrations' => $activeIntegrations,
'integrationTranslationsMap' => $integrationTranslationsMap,
'productFormAction' => $this->editPath($integrationId, $externalProductId, true),
'productBackUrl' => '/marketplace/' . $integrationId,
'integrationEditMode' => true,
'initialContentTab' => 'integration-' . $integrationId,
], 'layouts/app');
return Response::html($html);
}
public function updateProduct(Request $request): Response
{
$csrfToken = (string) $request->input('_token', '');
if (!Csrf::validate($csrfToken)) {
Flash::set('marketplace_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/marketplace');
}
$integrationId = max(0, (int) $request->input('integration_id', 0));
$externalProductId = max(0, (int) $request->input('external_product_id', 0));
if ($integrationId <= 0 || $externalProductId <= 0) {
Flash::set('marketplace_error', $this->translator->get('marketplace.flash.integration_not_found'));
return Response::redirect('/marketplace');
}
$editPath = $this->editPath($integrationId, $externalProductId, false);
$integration = $this->marketplace->findActiveIntegrationById($integrationId);
if ($integration === null) {
Flash::set('marketplace_error', $this->translator->get('marketplace.flash.integration_not_found'));
return Response::redirect('/marketplace');
}
$credentials = $this->integrationRepository->findApiCredentials($integrationId);
if ($credentials === null) {
Flash::set('marketplace_error', $this->translator->get('marketplace.flash.integration_not_found'));
return Response::redirect('/marketplace/' . $integrationId);
}
$localProductId = $this->integrationRepository->findMappedProductId(
'shoppro',
(string) $externalProductId,
$integrationId
);
if ($localProductId === null || $localProductId <= 0) {
Flash::set('marketplace_error', $this->translator->get('products.flash.not_found'));
return Response::redirect('/marketplace/' . $integrationId);
}
$payload = $this->payloadFromRequest($request);
Flash::set('products_form_old', $payload);
$validationErrors = $this->productValidator->validate($payload, true);
$sku = trim((string) ($payload['sku'] ?? ''));
if ($sku !== '' && $this->productRepository->existsSku($sku, $localProductId)) {
$validationErrors[] = 'Podane SKU produktu jest juz zajete.';
}
if ($validationErrors !== []) {
Flash::set('products_form_errors', $validationErrors);
return Response::redirect($editPath);
}
$allowedIntegrationIds = array_map(
static fn (array $i): int => (int) ($i['id'] ?? 0),
$this->integrationRepository->listByType('shoppro')
);
$integrationContent = $request->input('integration_content', []);
$remotePayload = $this->buildRemoteUpdatePayload(
$payload,
is_array($integrationContent) ? $integrationContent : [],
$integrationId,
$localProductId
);
$remoteUpdate = $this->shopProClient->updateProduct(
(string) ($credentials['base_url'] ?? ''),
(string) ($credentials['api_key'] ?? ''),
(int) ($credentials['timeout_seconds'] ?? 10),
$externalProductId,
$remotePayload
);
if (($remoteUpdate['ok'] ?? false) !== true) {
Flash::set('products_form_errors', [(string) ($remoteUpdate['message'] ?? 'Nie mozna zapisac produktu w shopPRO.')]);
return Response::redirect($editPath);
}
$updatedOfferName = trim((string) ($remotePayload['languages']['pl']['name'] ?? ''));
if ($updatedOfferName !== '') {
$this->marketplace->updateCachedOfferNameForExternalProduct(
$integrationId,
(string) $externalProductId,
$updatedOfferName
);
}
$result = $this->productService->update($localProductId, $payload, $this->auth->user());
if (($result['ok'] ?? false) !== true) {
Flash::set('products_form_errors', (array) ($result['errors'] ?? ['Nie udalo sie zapisac lokalnych zmian produktu.']));
return Response::redirect($editPath);
}
if (is_array($integrationContent)) {
foreach ($integrationContent as $rawIntegrationId => $content) {
$contentIntegrationId = (int) $rawIntegrationId;
if ($contentIntegrationId <= 0 || !is_array($content) || !in_array($contentIntegrationId, $allowedIntegrationIds, true)) {
continue;
}
$this->productRepository->upsertIntegrationTranslation(
$localProductId,
$contentIntegrationId,
isset($content['name']) ? trim((string) $content['name']) : null,
isset($content['short_description']) ? trim((string) $content['short_description']) : null,
isset($content['description']) ? trim((string) $content['description']) : null
);
}
}
Flash::set('products_form_old', []);
Flash::set('products_form_errors', []);
Flash::set('marketplace_success', $this->translator->get('marketplace.flash.product_updated'));
return Response::redirect('/marketplace/' . $integrationId);
}
public function categoriesJson(Request $request): Response
{
$integrationId = max(0, (int) $request->input('integration_id', 0));
if ($integrationId <= 0) {
return Response::json(['ok' => false, 'message' => 'Brak integration_id.'], 400);
}
$integration = $this->marketplace->findActiveIntegrationById($integrationId);
if ($integration === null) {
return Response::json(['ok' => false, 'message' => 'Integracja nie istnieje lub jest nieaktywna.'], 404);
}
$creds = $this->integrationRepository->findApiCredentials($integrationId);
if ($creds === null) {
return Response::json(['ok' => false, 'message' => 'Brak danych uwierzytelniających.'], 404);
}
$result = $this->shopProClient->fetchCategories(
(string) ($creds['base_url'] ?? ''),
(string) ($creds['api_key'] ?? ''),
(int) ($creds['timeout_seconds'] ?? 10)
);
if (!($result['ok'] ?? false)) {
return Response::json(['ok' => false, 'message' => $result['message']], 502);
}
return Response::json(['ok' => true, 'categories' => $result['categories']]);
}
public function productCategoriesJson(Request $request): Response
{
$integrationId = max(0, (int) $request->input('integration_id', 0));
$externalProductId = max(0, (int) $request->input('external_product_id', 0));
if ($integrationId <= 0 || $externalProductId <= 0) {
return Response::json(['ok' => false, 'message' => 'Brak wymaganych parametrów.'], 400);
}
$integration = $this->marketplace->findActiveIntegrationById($integrationId);
if ($integration === null) {
return Response::json(['ok' => false, 'message' => 'Integracja nie istnieje.'], 404);
}
$creds = $this->integrationRepository->findApiCredentials($integrationId);
if ($creds === null) {
return Response::json(['ok' => false, 'message' => 'Brak danych uwierzytelniających.'], 404);
}
$result = $this->shopProClient->fetchProductById(
(string) ($creds['base_url'] ?? ''),
(string) ($creds['api_key'] ?? ''),
(int) ($creds['timeout_seconds'] ?? 10),
$externalProductId
);
if (!($result['ok'] ?? false)) {
return Response::json(['ok' => false, 'message' => $result['message']], 502);
}
$product = is_array($result['product'] ?? null) ? $result['product'] : [];
$categoryIds = isset($product['categories']) && is_array($product['categories'])
? array_values(array_filter(array_map('intval', $product['categories']), static fn(int $id): bool => $id > 0))
: [];
return Response::json(['ok' => true, 'current_category_ids' => $categoryIds]);
}
public function saveProductCategoriesJson(Request $request): Response
{
$integrationId = max(0, (int) $request->input('integration_id', 0));
$externalProductId = max(0, (int) $request->input('external_product_id', 0));
if ($integrationId <= 0 || $externalProductId <= 0) {
return Response::json(['ok' => false, 'message' => 'Brak wymaganych parametrów.'], 400);
}
$rawBody = (string) file_get_contents('php://input');
$body = json_decode($rawBody, true);
if (!is_array($body)) {
return Response::json(['ok' => false, 'message' => 'Nieprawidłowe ciało żądania JSON.'], 400);
}
$csrfToken = (string) ($body['_token'] ?? '');
if (!Csrf::validate($csrfToken)) {
return Response::json(['ok' => false, 'message' => 'Nieprawidłowy token CSRF.'], 403);
}
$integration = $this->marketplace->findActiveIntegrationById($integrationId);
if ($integration === null) {
return Response::json(['ok' => false, 'message' => 'Integracja nie istnieje lub jest nieaktywna.'], 404);
}
$creds = $this->integrationRepository->findApiCredentials($integrationId);
if ($creds === null) {
return Response::json(['ok' => false, 'message' => 'Brak danych uwierzytelniających.'], 404);
}
$categoryIds = isset($body['category_ids']) && is_array($body['category_ids'])
? array_values(array_filter(array_map('intval', $body['category_ids']), static fn(int $id): bool => $id > 0))
: [];
$payload = ['categories' => $categoryIds];
// Include language content so shopPRO doesn't clear name/description when saving categories.
// Use per-integration translation if set, otherwise fall back to global product translation.
$localProductId = $this->integrationRepository->findMappedProductId(
'shoppro',
(string) $externalProductId,
$integrationId
);
if ($localProductId !== null && $localProductId > 0) {
$integrationTranslation = null;
foreach ($this->productRepository->findIntegrationTranslations($localProductId) as $row) {
if ((int) ($row['integration_id'] ?? 0) === $integrationId) {
$integrationTranslation = $row;
break;
}
}
$global = $this->productRepository->findById($localProductId, 'pl');
if ($global !== null) {
$name = (string) ($integrationTranslation['name'] ?? $global['name'] ?? '');
$payload['languages'] = [
'pl' => [
'name' => $name !== '' ? $name : ('orderPRO #' . $localProductId),
'short_description' => $integrationTranslation['short_description'] ?? $global['short_description'] ?? null,
'description' => $integrationTranslation['description'] ?? $global['description'] ?? null,
'meta_title' => $global['meta_title'] ?? null,
'meta_description' => $global['meta_description'] ?? null,
'meta_keywords' => $global['meta_keywords'] ?? null,
'seo_link' => $global['seo_link'] ?? null,
],
];
}
}
$result = $this->shopProClient->updateProduct(
(string) ($creds['base_url'] ?? ''),
(string) ($creds['api_key'] ?? ''),
(int) ($creds['timeout_seconds'] ?? 10),
$externalProductId,
$payload
);
if (!($result['ok'] ?? false)) {
return Response::json(['ok' => false, 'message' => $result['message']], 502);
}
return Response::json(['ok' => true]);
}
private function editPath(int $integrationId, int $externalProductId, bool $forRenderAction): string
{
$suffix = $forRenderAction ? 'update' : 'edit';
return '/marketplace/' . $integrationId . '/product/' . $externalProductId . '/' . $suffix;
}
/**
* @param array<string, mixed> $externalProduct
* @return array<string, mixed>
*/
private function mapExternalProductToForm(array $externalProduct, int $externalProductId): array
{
$lang = $this->resolveProductLanguage($externalProduct);
$name = trim((string) ($lang['name'] ?? ''));
if ($name === '') {
$name = 'shopPRO #' . $externalProductId;
}
return [
'type' => (array_key_exists('variants', $externalProduct) && is_array($externalProduct['variants']) && $externalProduct['variants'] !== [])
? 'variant_parent'
: 'simple',
'name' => $name,
'sku' => trim((string) ($externalProduct['sku'] ?? '')),
'ean' => trim((string) ($externalProduct['ean'] ?? '')),
'status' => ((int) ($externalProduct['status'] ?? 1)) === 1 ? '1' : '0',
'promoted' => ((int) ($externalProduct['promoted'] ?? 0)) === 1 ? '1' : '0',
'vat' => (string) ($externalProduct['vat'] ?? ''),
'weight' => (string) ($externalProduct['weight'] ?? ''),
'quantity' => (string) ($externalProduct['quantity'] ?? '0'),
'price_input_mode' => 'brutto',
'price_brutto' => (string) ($externalProduct['price_brutto'] ?? ''),
'price_netto' => (string) ($externalProduct['price_netto'] ?? ''),
'price_brutto_promo' => (string) ($externalProduct['price_brutto_promo'] ?? ''),
'price_netto_promo' => (string) ($externalProduct['price_netto_promo'] ?? ''),
'short_description' => trim((string) ($lang['short_description'] ?? '')),
'description' => trim((string) ($lang['description'] ?? '')),
'meta_title' => trim((string) ($lang['meta_title'] ?? '')),
'meta_description' => trim((string) ($lang['meta_description'] ?? '')),
'meta_keywords' => trim((string) ($lang['meta_keywords'] ?? '')),
'seo_link' => trim((string) ($lang['seo_link'] ?? '')),
];
}
/**
* @param array<string, mixed> $externalProduct
* @return array<string, mixed>
*/
private function resolveProductLanguage(array $externalProduct): array
{
$languages = $externalProduct['languages'] ?? null;
if (!is_array($languages)) {
return [];
}
if (isset($languages['pl']) && is_array($languages['pl'])) {
return $languages['pl'];
}
foreach ($languages as $language) {
if (is_array($language)) {
return $language;
}
}
return [];
}
/**
* @return array<string, mixed>
*/
private function payloadFromRequest(Request $request): array
{
return [
'type' => (string) $request->input('type', 'simple'),
'name' => (string) $request->input('name', ''),
'sku' => (string) $request->input('sku', ''),
'ean' => (string) $request->input('ean', ''),
'status' => (string) $request->input('status', '1'),
'promoted' => (string) $request->input('promoted', '0'),
'vat' => (string) $request->input('vat', '23'),
'weight' => (string) $request->input('weight', ''),
'quantity' => (string) $request->input('quantity', '0'),
'price_input_mode' => (string) $request->input('price_input_mode', 'brutto'),
'price_brutto' => (string) $request->input('price_brutto', ''),
'price_netto' => (string) $request->input('price_netto', ''),
'price_brutto_promo' => (string) $request->input('price_brutto_promo', ''),
'price_netto_promo' => (string) $request->input('price_netto_promo', ''),
'short_description' => (string) $request->input('short_description', ''),
'description' => (string) $request->input('description', ''),
'meta_title' => (string) $request->input('meta_title', ''),
'meta_description' => (string) $request->input('meta_description', ''),
'meta_keywords' => (string) $request->input('meta_keywords', ''),
'seo_link' => (string) $request->input('seo_link', ''),
];
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $integrationContent
* @return array<string, mixed>
*/
private function buildRemoteUpdatePayload(
array $payload,
array $integrationContent,
int $integrationId,
int $localProductId
): array {
$integrationOverride = $integrationContent[$integrationId] ?? null;
$overrideName = is_array($integrationOverride) ? trim((string) ($integrationOverride['name'] ?? '')) : '';
$overrideShort = is_array($integrationOverride) ? trim((string) ($integrationOverride['short_description'] ?? '')) : '';
$overrideDesc = is_array($integrationOverride) ? trim((string) ($integrationOverride['description'] ?? '')) : '';
$name = trim((string) ($payload['name'] ?? ''));
if ($overrideName !== '') {
$name = $overrideName;
}
if ($name === '') {
$name = 'orderPRO #' . $localProductId;
}
$shortDescription = trim((string) ($payload['short_description'] ?? ''));
if ($overrideShort !== '') {
$shortDescription = $overrideShort;
}
$description = trim((string) ($payload['description'] ?? ''));
if ($overrideDesc !== '') {
$description = $overrideDesc;
}
return [
'price_brutto' => round((float) ($payload['price_brutto'] ?? 0), 2),
'price_brutto_promo' => $this->nullableFloat($payload['price_brutto_promo'] ?? null, 2),
'price_netto' => $this->nullableFloat($payload['price_netto'] ?? null, 2),
'price_netto_promo' => $this->nullableFloat($payload['price_netto_promo'] ?? null, 2),
'vat' => $this->nullableFloat($payload['vat'] ?? null, 2),
'quantity' => round((float) ($payload['quantity'] ?? 0), 3),
'status' => ((int) ($payload['status'] ?? 0)) === 1 ? 1 : 0,
'promoted' => ((int) ($payload['promoted'] ?? 0)) === 1 ? 1 : 0,
'sku' => $this->nullableText($payload['sku'] ?? null),
'ean' => $this->nullableText($payload['ean'] ?? null),
'weight' => $this->nullableFloat($payload['weight'] ?? null, 3),
'languages' => [
'pl' => [
'name' => $name,
'short_description' => $this->nullableText($shortDescription),
'description' => $this->nullableText($description),
'meta_title' => $this->nullableText($payload['meta_title'] ?? null),
'meta_description' => $this->nullableText($payload['meta_description'] ?? null),
'meta_keywords' => $this->nullableText($payload['meta_keywords'] ?? null),
'seo_link' => $this->nullableText($payload['seo_link'] ?? null),
],
],
];
}
private function nullableText(mixed $value): ?string
{
$text = trim((string) $value);
return $text === '' ? null : $text;
}
private function nullableFloat(mixed $value, int $precision = 2): ?float
{
$text = trim((string) $value);
if ($text === '' || !is_numeric($text)) {
return null;
}
return round((float) $text, $precision);
}
}

View File

@@ -1,279 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Marketplace;
use PDO;
final class MarketplaceRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return array<int, array<string, mixed>>
*/
public function listActiveIntegrationsWithCounts(): array
{
$statement = $this->pdo->query(
'SELECT i.id, i.name,
(
SELECT COUNT(1)
FROM product_channel_map pcm2
WHERE pcm2.integration_id = i.id
AND pcm2.link_status = "active"
AND pcm2.external_product_id IS NOT NULL
AND pcm2.external_product_id <> ""
) AS linked_offers_count
FROM integrations i
WHERE i.type = "shoppro"
AND i.is_active = 1
ORDER BY i.name ASC, i.id ASC'
);
$rows = $statement->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map(
static fn (array $row): array => [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'linked_offers_count' => (int) ($row['linked_offers_count'] ?? 0),
],
$rows
);
}
/**
* @return array<string, mixed>|null
*/
public function findActiveIntegrationById(int $integrationId): ?array
{
$statement = $this->pdo->prepare(
'SELECT id, name
FROM integrations
WHERE id = :id
AND type = :type
AND is_active = 1
LIMIT 1'
);
$statement->execute([
'id' => $integrationId,
'type' => 'shoppro',
]);
$row = $statement->fetch();
if (!is_array($row)) {
return null;
}
return [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
];
}
/**
* @param array<string, mixed> $filters
* @return array{items:array<int, array<string, mixed>>, total:int, page:int, per_page:int}
*/
public function paginateLinkedOffersByIntegration(int $integrationId, array $filters, string $lang = 'pl'): array
{
$page = max(1, (int) ($filters['page'] ?? 1));
$perPage = max(1, min(100, (int) ($filters['per_page'] ?? 20)));
$offset = ($page - 1) * $perPage;
[$whereSql, $params] = $this->buildOfferFilters($integrationId, $filters);
$sort = $this->resolveOfferSort((string) ($filters['sort'] ?? 'updated_at'));
$sortDir = strtoupper((string) ($filters['sort_dir'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
$countStmt = $this->pdo->prepare(
'SELECT COUNT(*)
FROM product_channel_map pcm
INNER JOIN products p ON p.id = pcm.product_id AND p.deleted_at IS NULL
LEFT JOIN product_translations pt ON pt.product_id = p.id AND pt.lang = :lang_count
LEFT JOIN sales_channels sc ON sc.id = pcm.channel_id
LEFT JOIN channel_offers co
ON co.integration_id = pcm.integration_id
AND co.external_product_id = pcm.external_product_id
AND (
(co.external_variant_id IS NULL AND pcm.external_variant_id IS NULL)
OR co.external_variant_id = pcm.external_variant_id
)
' . $whereSql
);
$countStmt->execute(array_merge(['lang_count' => $lang], $params));
$total = (int) $countStmt->fetchColumn();
$statement = $this->pdo->prepare(
'SELECT pcm.id,
pcm.product_id,
pcm.external_product_id,
pcm.external_variant_id,
pcm.updated_at,
p.sku AS product_sku,
p.ean AS product_ean,
COALESCE(pt.name, "") AS product_name,
sc.name AS channel_name,
COALESCE(co.name, "") AS offer_name,
co.external_offer_id
FROM product_channel_map pcm
INNER JOIN products p ON p.id = pcm.product_id AND p.deleted_at IS NULL
LEFT JOIN product_translations pt ON pt.product_id = p.id AND pt.lang = :lang
LEFT JOIN sales_channels sc ON sc.id = pcm.channel_id
LEFT JOIN channel_offers co
ON co.integration_id = pcm.integration_id
AND co.external_product_id = pcm.external_product_id
AND (
(co.external_variant_id IS NULL AND pcm.external_variant_id IS NULL)
OR co.external_variant_id = pcm.external_variant_id
)
' . $whereSql . '
ORDER BY ' . $sort . ' ' . $sortDir . '
LIMIT :limit OFFSET :offset'
);
foreach (array_merge(['lang' => $lang], $params) as $key => $value) {
$statement->bindValue(':' . $key, $value);
}
$statement->bindValue(':limit', $perPage, PDO::PARAM_INT);
$statement->bindValue(':offset', $offset, PDO::PARAM_INT);
$statement->execute();
$rows = $statement->fetchAll();
if (!is_array($rows)) {
$rows = [];
}
return [
'items' => array_map(
static fn (array $row): array => [
'id' => (int) ($row['id'] ?? 0),
'product_id' => (int) ($row['product_id'] ?? 0),
'product_name' => (string) ($row['product_name'] ?? ''),
'product_sku' => (string) ($row['product_sku'] ?? ''),
'product_ean' => (string) ($row['product_ean'] ?? ''),
'channel_name' => (string) ($row['channel_name'] ?? ''),
'offer_name' => (string) ($row['offer_name'] ?? ''),
'external_product_id' => (string) ($row['external_product_id'] ?? ''),
'external_variant_id' => isset($row['external_variant_id']) ? (string) $row['external_variant_id'] : '',
'external_offer_id' => isset($row['external_offer_id']) ? (string) $row['external_offer_id'] : '',
'updated_at' => (string) ($row['updated_at'] ?? ''),
],
$rows
),
'total' => $total,
'page' => $page,
'per_page' => $perPage,
];
}
/**
* @return array<int, string>
*/
public function listOfferChannelsByIntegration(int $integrationId): array
{
$stmt = $this->pdo->prepare(
'SELECT DISTINCT sc.name
FROM product_channel_map pcm
LEFT JOIN sales_channels sc ON sc.id = pcm.channel_id
WHERE pcm.integration_id = :integration_id
AND pcm.link_status = :link_status
AND pcm.external_product_id IS NOT NULL
AND pcm.external_product_id <> ""
AND sc.name IS NOT NULL
AND sc.name <> ""
ORDER BY sc.name ASC'
);
$stmt->execute([
'integration_id' => $integrationId,
'link_status' => 'active',
]);
$rows = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (!is_array($rows)) {
return [];
}
return array_values(array_filter(array_map(
static fn (mixed $v): string => trim((string) $v),
$rows
), static fn (string $v): bool => $v !== ''));
}
/**
* @param array<string, mixed> $filters
* @return array{0:string,1:array<string,mixed>}
*/
private function buildOfferFilters(int $integrationId, array $filters): array
{
$where = [
'pcm.integration_id = :integration_id',
'pcm.link_status = :link_status',
'pcm.external_product_id IS NOT NULL',
'pcm.external_product_id <> ""',
];
$params = [
'integration_id' => $integrationId,
'link_status' => 'active',
];
$search = trim((string) ($filters['search'] ?? ''));
if ($search !== '') {
$where[] = '(co.name LIKE :search
OR pcm.external_product_id LIKE :search
OR pcm.external_variant_id LIKE :search
OR co.external_offer_id LIKE :search
OR pt.name LIKE :search
OR p.sku LIKE :search
OR p.ean LIKE :search)';
$params['search'] = '%' . $search . '%';
}
$channel = trim((string) ($filters['channel'] ?? ''));
if ($channel !== '') {
$where[] = 'sc.name = :channel';
$params['channel'] = $channel;
}
return ['WHERE ' . implode(' AND ', $where), $params];
}
private function resolveOfferSort(string $sort): string
{
return match ($sort) {
'offer_name' => 'co.name',
'external_product_id' => 'pcm.external_product_id',
'external_variant_id' => 'pcm.external_variant_id',
'external_offer_id' => 'co.external_offer_id',
'channel_name' => 'sc.name',
'product_name' => 'pt.name',
'product_sku' => 'p.sku',
'product_ean' => 'p.ean',
default => 'pcm.updated_at',
};
}
public function updateCachedOfferNameForExternalProduct(int $integrationId, string $externalProductId, string $offerName): int
{
$normalizedExternalProductId = trim($externalProductId);
if ($integrationId <= 0 || $normalizedExternalProductId === '') {
return 0;
}
$statement = $this->pdo->prepare(
'UPDATE channel_offers
SET name = :name, updated_at = :updated_at
WHERE integration_id = :integration_id
AND external_product_id = :external_product_id'
);
$statement->execute([
'name' => trim($offerName),
'updated_at' => date('Y-m-d H:i:s'),
'integration_id' => $integrationId,
'external_product_id' => $normalizedExternalProductId,
]);
return $statement->rowCount();
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,270 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\ProductLinks;
use PDO;
final class ChannelOffersRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return array<int, array<string, mixed>>
*/
public function listByIntegration(int $integrationId, int $limit = 100): array
{
$statement = $this->pdo->prepare(
'SELECT co.id, co.integration_id, co.channel_id,
co.external_product_id, co.external_variant_id, co.external_offer_id,
co.name, co.sku, co.ean, co.price_brutto, co.quantity, co.currency,
co.offer_status, co.source_updated_at, co.last_seen_at, co.payload_json,
co.created_at, co.updated_at,
sc.code AS channel_code, sc.name AS channel_name
FROM channel_offers co
INNER JOIN sales_channels sc ON sc.id = co.channel_id
WHERE co.integration_id = :integration_id
ORDER BY co.last_seen_at DESC, co.id DESC
LIMIT :limit'
);
$statement->bindValue(':integration_id', $integrationId, PDO::PARAM_INT);
$statement->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
$statement->execute();
$rows = $statement->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map([$this, 'mapOfferRow'], $rows);
}
/**
* @return array<int, array<string, mixed>>
*/
public function search(
int $integrationId,
?int $channelId,
string $query,
int $limit = 50
): array {
$sql = 'SELECT co.id, co.integration_id, co.channel_id,
co.external_product_id, co.external_variant_id, co.external_offer_id,
co.name, co.sku, co.ean, co.price_brutto, co.quantity, co.currency,
co.offer_status, co.source_updated_at, co.last_seen_at, co.payload_json,
co.created_at, co.updated_at,
sc.code AS channel_code, sc.name AS channel_name
FROM channel_offers co
INNER JOIN sales_channels sc ON sc.id = co.channel_id
WHERE co.integration_id = :integration_id';
$params = ['integration_id' => $integrationId];
if ($channelId !== null && $channelId > 0) {
$sql .= ' AND co.channel_id = :channel_id';
$params['channel_id'] = $channelId;
}
$needle = trim($query);
if ($needle !== '') {
$sql .= ' AND (
co.name LIKE :query_name
OR co.sku LIKE :query_sku
OR co.ean LIKE :query_ean
OR co.external_product_id LIKE :query_external_product_id
)';
$like = '%' . $needle . '%';
$params['query_name'] = $like;
$params['query_sku'] = $like;
$params['query_ean'] = $like;
$params['query_external_product_id'] = $like;
}
$sql .= ' ORDER BY co.last_seen_at DESC, co.id DESC LIMIT :limit';
$statement = $this->pdo->prepare($sql);
foreach ($params as $key => $value) {
$statement->bindValue(':' . $key, $value);
}
$statement->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
$statement->execute();
$rows = $statement->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map([$this, 'mapOfferRow'], $rows);
}
public function upsertOffer(
int $integrationId,
int $channelId,
string $externalProductId,
?string $externalVariantId,
?string $externalOfferId,
string $name,
?string $sku,
?string $ean,
?float $priceBrutto,
?float $quantity,
?string $currency,
string $offerStatus,
?string $sourceUpdatedAt,
string $lastSeenAt,
?string $payloadJson
): void {
$statement = $this->pdo->prepare(
'INSERT INTO channel_offers (
integration_id, channel_id, external_product_id, external_variant_id, external_offer_id,
name, sku, ean, price_brutto, quantity, currency, offer_status,
source_updated_at, last_seen_at, payload_json, created_at, updated_at
) VALUES (
:integration_id, :channel_id, :external_product_id, :external_variant_id, :external_offer_id,
:name, :sku, :ean, :price_brutto, :quantity, :currency, :offer_status,
:source_updated_at, :last_seen_at, :payload_json, :created_at, :updated_at
) ON DUPLICATE KEY UPDATE
channel_id = VALUES(channel_id),
external_offer_id = VALUES(external_offer_id),
name = VALUES(name),
sku = VALUES(sku),
ean = VALUES(ean),
price_brutto = VALUES(price_brutto),
quantity = VALUES(quantity),
currency = VALUES(currency),
offer_status = VALUES(offer_status),
source_updated_at = VALUES(source_updated_at),
last_seen_at = VALUES(last_seen_at),
payload_json = VALUES(payload_json),
updated_at = VALUES(updated_at)'
);
$now = date('Y-m-d H:i:s');
$statement->execute([
'integration_id' => $integrationId,
'channel_id' => $channelId,
'external_product_id' => trim($externalProductId),
'external_variant_id' => $this->nullableText($externalVariantId),
'external_offer_id' => $this->nullableText($externalOfferId),
'name' => trim($name),
'sku' => $this->nullableText($sku),
'ean' => $this->nullableText($ean),
'price_brutto' => $priceBrutto,
'quantity' => $quantity,
'currency' => $this->nullableText($currency),
'offer_status' => trim($offerStatus),
'source_updated_at' => $this->nullableText($sourceUpdatedAt),
'last_seen_at' => $lastSeenAt,
'payload_json' => $this->nullableJson($payloadJson),
'created_at' => $now,
'updated_at' => $now,
]);
}
public function removeStaleByIntegration(int $integrationId, string $lastSeenThreshold): int
{
$statement = $this->pdo->prepare(
'DELETE FROM channel_offers
WHERE integration_id = :integration_id
AND last_seen_at < :last_seen_threshold'
);
$statement->execute([
'integration_id' => $integrationId,
'last_seen_threshold' => trim($lastSeenThreshold),
]);
return $statement->rowCount();
}
/**
* @return array<string, mixed>|null
*/
public function findByExternalIdentity(
int $integrationId,
string $externalProductId,
?string $externalVariantId
): ?array {
$statement = $this->pdo->prepare(
'SELECT co.id, co.integration_id, co.channel_id,
co.external_product_id, co.external_variant_id, co.external_offer_id,
co.name, co.sku, co.ean, co.price_brutto, co.quantity, co.currency,
co.offer_status, co.source_updated_at, co.last_seen_at, co.payload_json,
co.created_at, co.updated_at,
sc.code AS channel_code, sc.name AS channel_name
FROM channel_offers co
INNER JOIN sales_channels sc ON sc.id = co.channel_id
WHERE co.integration_id = :integration_id
AND co.external_product_id = :external_product_id
AND (
(:external_variant_id_value IS NULL AND co.external_variant_id IS NULL)
OR co.external_variant_id = :external_variant_id_match
)
ORDER BY co.id DESC
LIMIT 1'
);
$statement->execute([
'integration_id' => $integrationId,
'external_product_id' => trim($externalProductId),
'external_variant_id_value' => $this->nullableText($externalVariantId),
'external_variant_id_match' => $this->nullableText($externalVariantId),
]);
$row = $statement->fetch();
if (!is_array($row)) {
return null;
}
return $this->mapOfferRow($row);
}
private function nullableText(?string $value): ?string
{
if ($value === null) {
return null;
}
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function nullableJson(?string $payload): ?string
{
if ($payload === null) {
return null;
}
$trimmed = trim($payload);
return $trimmed === '' ? null : $trimmed;
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function mapOfferRow(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'integration_id' => (int) ($row['integration_id'] ?? 0),
'channel_id' => (int) ($row['channel_id'] ?? 0),
'external_product_id' => (string) ($row['external_product_id'] ?? ''),
'external_variant_id' => isset($row['external_variant_id']) ? (string) $row['external_variant_id'] : null,
'external_offer_id' => isset($row['external_offer_id']) ? (string) $row['external_offer_id'] : null,
'name' => (string) ($row['name'] ?? ''),
'sku' => isset($row['sku']) ? (string) $row['sku'] : null,
'ean' => isset($row['ean']) ? (string) $row['ean'] : null,
'price_brutto' => $row['price_brutto'] === null ? null : (float) $row['price_brutto'],
'quantity' => $row['quantity'] === null ? null : (float) $row['quantity'],
'currency' => isset($row['currency']) ? (string) $row['currency'] : null,
'offer_status' => (string) ($row['offer_status'] ?? 'active'),
'source_updated_at' => isset($row['source_updated_at']) ? (string) $row['source_updated_at'] : null,
'last_seen_at' => (string) ($row['last_seen_at'] ?? ''),
'payload_json' => isset($row['payload_json']) ? (string) $row['payload_json'] : null,
'channel_code' => isset($row['channel_code']) ? (string) $row['channel_code'] : '',
'channel_name' => isset($row['channel_name']) ? (string) $row['channel_name'] : '',
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}
}

View File

@@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\ProductLinks;
final class LinkMatcherService
{
/**
* @param array<string, mixed> $product
* @param array<string, mixed> $offer
* @return array{match_hint:string,confidence:int,link_type:string}
*/
public function match(array $product, array $offer): array
{
$productEan = trim((string) ($product['ean'] ?? ''));
$productSku = trim((string) ($product['sku'] ?? ''));
$offerEan = trim((string) ($offer['ean'] ?? ''));
$offerSku = trim((string) ($offer['sku'] ?? ''));
if ($productEan !== '' && $offerEan !== '' && $productEan === $offerEan) {
return [
'match_hint' => 'EAN exact',
'confidence' => 98,
'link_type' => 'auto_ean',
];
}
if ($productSku !== '' && $offerSku !== '' && mb_strtolower($productSku) === mb_strtolower($offerSku)) {
return [
'match_hint' => 'SKU exact',
'confidence' => 90,
'link_type' => 'auto_sku',
];
}
$normalizedProductSku = $this->normalizeSku($productSku);
$normalizedOfferSku = $this->normalizeSku($offerSku);
if ($normalizedProductSku !== '' && $normalizedProductSku === $normalizedOfferSku) {
return [
'match_hint' => 'SKU normalized',
'confidence' => 75,
'link_type' => 'auto_sku',
];
}
return [
'match_hint' => '',
'confidence' => 0,
'link_type' => 'manual',
];
}
private function normalizeSku(string $value): string
{
if ($value === '') {
return '';
}
$lower = mb_strtolower($value);
return preg_replace('/[\s\-_]+/u', '', $lower) ?? '';
}
}

View File

@@ -1,230 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\ProductLinks;
use App\Modules\Settings\ShopProClient;
use PDO;
use Throwable;
final class OfferImportService
{
public function __construct(
private readonly ShopProClient $shopProClient,
private readonly ChannelOffersRepository $offers,
private readonly PDO $pdo
) {
}
/**
* @param array<string, mixed> $credentials
* @return array{
* ok:bool,
* imported:int,
* failed:int,
* pages:int,
* message:string
* }
*/
public function importShopProOffers(array $credentials, int $perPage = 100, int $maxPages = 200): array
{
$integrationId = (int) ($credentials['id'] ?? 0);
$baseUrl = trim((string) ($credentials['base_url'] ?? ''));
$apiKey = trim((string) ($credentials['api_key'] ?? ''));
$timeout = (int) ($credentials['timeout_seconds'] ?? 10);
if ($integrationId <= 0) {
return [
'ok' => false,
'imported' => 0,
'failed' => 0,
'pages' => 0,
'message' => 'Niepoprawne ID integracji.',
];
}
if ($baseUrl === '' || $apiKey === '') {
return [
'ok' => false,
'imported' => 0,
'failed' => 0,
'pages' => 0,
'message' => 'Brak base_url lub api_key dla integracji.',
];
}
$channelId = $this->findChannelIdByCode('shoppro');
if ($channelId === null) {
return [
'ok' => false,
'imported' => 0,
'failed' => 0,
'pages' => 0,
'message' => 'Brak kanalu sprzedazy shoppro. Uruchom seeding kanalow.',
];
}
$imported = 0;
$failed = 0;
$pages = 0;
$errors = [];
$syncStartedAt = date('Y-m-d H:i:s');
$page = 1;
$safePerPage = max(1, min(100, $perPage));
$safeMaxPages = max(1, min(500, $maxPages));
while ($page <= $safeMaxPages) {
$listResult = $this->shopProClient->fetchProducts($baseUrl, $apiKey, $timeout, $page, $safePerPage);
if (($listResult['ok'] ?? false) !== true) {
return [
'ok' => false,
'imported' => $imported,
'failed' => $failed,
'pages' => $pages,
'message' => 'Blad pobierania listy produktow (strona ' . $page . '): ' . (string) ($listResult['message'] ?? ''),
];
}
$items = is_array($listResult['items'] ?? null) ? $listResult['items'] : [];
if ($items === []) {
break;
}
$pages++;
foreach ($items as $item) {
$mapped = $this->mapExternalItem($item, $syncStartedAt);
if ($mapped === null) {
$failed++;
if (count($errors) < 3) {
$errors[] = 'Pominieto rekord bez poprawnego ID produktu z API.';
}
continue;
}
try {
$this->offers->upsertOffer(
$integrationId,
$channelId,
(string) $mapped['external_product_id'],
$mapped['external_variant_id'] === null ? null : (string) $mapped['external_variant_id'],
$mapped['external_offer_id'] === null ? null : (string) $mapped['external_offer_id'],
(string) $mapped['name'],
$mapped['sku'] === null ? null : (string) $mapped['sku'],
$mapped['ean'] === null ? null : (string) $mapped['ean'],
$mapped['price_brutto'] === null ? null : (float) $mapped['price_brutto'],
$mapped['quantity'] === null ? null : (float) $mapped['quantity'],
$mapped['currency'] === null ? null : (string) $mapped['currency'],
(string) $mapped['offer_status'],
$mapped['source_updated_at'] === null ? null : (string) $mapped['source_updated_at'],
(string) $mapped['last_seen_at'],
(string) $mapped['payload_json']
);
$imported++;
} catch (Throwable $exception) {
$failed++;
if (count($errors) < 3) {
$errors[] = $exception->getMessage();
}
}
}
if (count($items) < $safePerPage) {
break;
}
$page++;
}
$message = '';
if ($errors !== []) {
$message = implode(' | ', $errors);
}
try {
$this->offers->removeStaleByIntegration($integrationId, $syncStartedAt);
} catch (Throwable $exception) {
return [
'ok' => false,
'imported' => $imported,
'failed' => $failed,
'pages' => $pages,
'message' => 'Nie mozna wyczyscic nieaktualnych ofert: ' . $exception->getMessage(),
];
}
return [
'ok' => true,
'imported' => $imported,
'failed' => $failed,
'pages' => $pages,
'message' => $message,
];
}
private function findChannelIdByCode(string $code): ?int
{
$statement = $this->pdo->prepare('SELECT id FROM sales_channels WHERE code = :code LIMIT 1');
$statement->execute(['code' => trim($code)]);
$value = $statement->fetchColumn();
if ($value === false) {
return null;
}
return (int) $value;
}
/**
* @param array<string, mixed> $item
* @return array<string, mixed>|null
*/
private function mapExternalItem(array $item, string $lastSeenAt): ?array
{
$externalProductId = $this->nullableText($item['id'] ?? null);
if ($externalProductId === null) {
return null;
}
$name = $this->nullableText($item['name'] ?? null);
if ($name === null) {
$name = 'shopPRO #' . $externalProductId;
}
$payloadJson = json_encode($item, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($payloadJson === false) {
$payloadJson = '{}';
}
return [
'external_product_id' => $externalProductId,
'external_variant_id' => $this->nullableText($item['variant_id'] ?? $item['external_variant_id'] ?? null),
'external_offer_id' => $this->nullableText($item['offer_id'] ?? $item['external_offer_id'] ?? null),
'name' => $name,
'sku' => $this->nullableText($item['sku'] ?? null),
'ean' => $this->nullableText($item['ean'] ?? null),
'price_brutto' => $this->nullableFloat($item['price_brutto'] ?? $item['price'] ?? null),
'quantity' => $this->nullableFloat($item['quantity'] ?? null, 3),
'currency' => $this->nullableText($item['currency'] ?? null),
'offer_status' => ((int) ($item['status'] ?? 1)) === 1 ? 'active' : 'inactive',
'source_updated_at' => $this->nullableText($item['updated_at'] ?? $item['modified_at'] ?? null),
'last_seen_at' => $lastSeenAt,
'payload_json' => $payloadJson,
];
}
private function nullableText(mixed $value): ?string
{
$text = trim((string) $value);
return $text === '' ? null : $text;
}
private function nullableFloat(mixed $value, int $precision = 2): ?float
{
$text = trim((string) $value);
if ($text === '' || !is_numeric($text)) {
return null;
}
return round((float) $text, $precision);
}
}

View File

@@ -1,156 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\ProductLinks;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Modules\Auth\AuthService;
final class ProductLinksController
{
public function __construct(
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly ProductLinksService $service
) {
}
public function create(Request $request): Response
{
$productId = $this->resolvePositiveInt($request, ['id', 'product_id']);
if (!$this->validateCsrf($request, $productId)) {
return Response::redirect($this->linksPagePath($productId));
}
$integrationId = $this->resolvePositiveInt($request, ['integration_id']);
$externalProductId = (string) $request->input('external_product_id', '');
$externalVariantId = $this->nullableText((string) $request->input('external_variant_id', ''));
$userId = $this->resolveUserId();
$result = $this->service->createManualLink(
$productId,
$integrationId,
$externalProductId,
$externalVariantId,
$userId
);
if (($result['ok'] ?? false) !== true) {
Flash::set('product_links_error', (string) ($result['message'] ?? $this->translator->get('products.links.flash.link_failed')));
return Response::redirect($this->linksPagePath($productId));
}
Flash::set('product_links_success', $this->translator->get('products.links.flash.linked'));
return Response::redirect($this->linksPagePath($productId));
}
public function relink(Request $request): Response
{
$productId = $this->resolvePositiveInt($request, ['id', 'product_id']);
if (!$this->validateCsrf($request, $productId)) {
return Response::redirect($this->linksPagePath($productId));
}
$mapId = $this->resolvePositiveInt($request, ['mapId', 'map_id']);
$integrationId = $this->resolvePositiveInt($request, ['integration_id']);
$externalProductId = (string) $request->input('external_product_id', '');
$externalVariantId = $this->nullableText((string) $request->input('external_variant_id', ''));
$userId = $this->resolveUserId();
$result = $this->service->relink(
$productId,
$mapId,
$integrationId,
$externalProductId,
$externalVariantId,
$userId
);
if (($result['ok'] ?? false) !== true) {
Flash::set('product_links_error', (string) ($result['message'] ?? $this->translator->get('products.links.flash.relink_failed')));
return Response::redirect($this->linksPagePath($productId));
}
Flash::set('product_links_success', $this->translator->get('products.links.flash.relinked'));
return Response::redirect($this->linksPagePath($productId));
}
public function unlink(Request $request): Response
{
$productId = $this->resolvePositiveInt($request, ['id', 'product_id']);
if (!$this->validateCsrf($request, $productId)) {
return Response::redirect($this->linksPagePath($productId));
}
$mapId = $this->resolvePositiveInt($request, ['mapId', 'map_id']);
$userId = $this->resolveUserId();
$result = $this->service->unlink($productId, $mapId, $userId);
if (($result['ok'] ?? false) !== true) {
Flash::set('product_links_error', (string) ($result['message'] ?? $this->translator->get('products.links.flash.unlink_failed')));
return Response::redirect($this->linksPagePath($productId));
}
Flash::set('product_links_success', $this->translator->get('products.links.flash.unlinked'));
return Response::redirect($this->linksPagePath($productId));
}
private function validateCsrf(Request $request, int $productId): bool
{
$csrfToken = (string) $request->input('_token', '');
if (Csrf::validate($csrfToken)) {
return true;
}
Flash::set('product_links_error', $this->translator->get('auth.errors.csrf_expired'));
if ($productId > 0) {
Flash::set('products_error', $this->translator->get('auth.errors.csrf_expired'));
}
return false;
}
private function resolveUserId(): ?int
{
$user = $this->auth->user();
if (!is_array($user)) {
return null;
}
$id = (int) ($user['id'] ?? 0);
return $id > 0 ? $id : null;
}
private function nullableText(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
/**
* @param array<int, string> $keys
*/
private function resolvePositiveInt(Request $request, array $keys): int
{
foreach ($keys as $key) {
$value = max(0, (int) $request->input($key, 0));
if ($value > 0) {
return $value;
}
}
return 0;
}
private function linksPagePath(int $productId): string
{
if ($productId > 0) {
return '/products/' . $productId . '/links';
}
return '/products/links';
}
}

View File

@@ -1,515 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\ProductLinks;
use PDO;
final class ProductLinksRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return array<int, array<string, mixed>>
*/
public function listByProductId(int $productId): array
{
$statement = $this->pdo->prepare(
'SELECT pcm.id, pcm.product_id, pcm.channel_id, pcm.integration_id,
pcm.external_product_id, pcm.external_variant_id, pcm.sync_state,
pcm.link_type, pcm.link_status, pcm.confidence,
pcm.linked_at, pcm.linked_by_user_id, pcm.unlinked_at, pcm.unlinked_by_user_id,
pcm.sync_meta_json, pcm.last_sync_at, pcm.created_at, pcm.updated_at,
sc.code AS channel_code, sc.name AS channel_name,
i.name AS integration_name,
pla.message AS missing_alert_message,
pla.first_detected_at AS missing_alert_first_detected_at,
pla.last_detected_at AS missing_alert_last_detected_at
FROM product_channel_map pcm
INNER JOIN sales_channels sc ON sc.id = pcm.channel_id
LEFT JOIN integrations i ON i.id = pcm.integration_id
LEFT JOIN product_link_alerts pla
ON pla.product_channel_map_id = pcm.id
AND pla.alert_type = \'missing_remote_link\'
AND pla.status = \'active\'
WHERE pcm.product_id = :product_id
ORDER BY pcm.id DESC'
);
$statement->execute(['product_id' => $productId]);
$rows = $statement->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map([$this, 'mapLinkRow'], $rows);
}
public function findById(int $id): ?array
{
$statement = $this->pdo->prepare(
'SELECT pcm.id, pcm.product_id, pcm.channel_id, pcm.integration_id,
pcm.external_product_id, pcm.external_variant_id, pcm.sync_state,
pcm.link_type, pcm.link_status, pcm.confidence,
pcm.linked_at, pcm.linked_by_user_id, pcm.unlinked_at, pcm.unlinked_by_user_id,
pcm.sync_meta_json, pcm.last_sync_at, pcm.created_at, pcm.updated_at,
sc.code AS channel_code, sc.name AS channel_name,
i.name AS integration_name,
pla.message AS missing_alert_message,
pla.first_detected_at AS missing_alert_first_detected_at,
pla.last_detected_at AS missing_alert_last_detected_at
FROM product_channel_map pcm
INNER JOIN sales_channels sc ON sc.id = pcm.channel_id
LEFT JOIN integrations i ON i.id = pcm.integration_id
LEFT JOIN product_link_alerts pla
ON pla.product_channel_map_id = pcm.id
AND pla.alert_type = \'missing_remote_link\'
AND pla.status = \'active\'
WHERE pcm.id = :id
LIMIT 1'
);
$statement->execute(['id' => $id]);
$row = $statement->fetch();
if (!is_array($row)) {
return null;
}
return $this->mapLinkRow($row);
}
public function findByProductAndIdentity(
int $productId,
int $integrationId,
string $externalProductId,
?string $externalVariantId
): ?array {
$statement = $this->pdo->prepare(
'SELECT pcm.id, pcm.product_id, pcm.channel_id, pcm.integration_id,
pcm.external_product_id, pcm.external_variant_id, pcm.sync_state,
pcm.link_type, pcm.link_status, pcm.confidence,
pcm.linked_at, pcm.linked_by_user_id, pcm.unlinked_at, pcm.unlinked_by_user_id,
pcm.sync_meta_json, pcm.last_sync_at, pcm.created_at, pcm.updated_at,
sc.code AS channel_code, sc.name AS channel_name,
i.name AS integration_name
FROM product_channel_map pcm
INNER JOIN sales_channels sc ON sc.id = pcm.channel_id
LEFT JOIN integrations i ON i.id = pcm.integration_id
WHERE pcm.product_id = :product_id
AND pcm.integration_id = :integration_id
AND pcm.external_product_id = :external_product_id
AND (
(:external_variant_id_value IS NULL AND pcm.external_variant_id IS NULL)
OR pcm.external_variant_id = :external_variant_id_match
)
ORDER BY pcm.id DESC
LIMIT 1'
);
$statement->execute([
'product_id' => $productId,
'integration_id' => $integrationId,
'external_product_id' => trim($externalProductId),
'external_variant_id_value' => $this->normalizeNullableText($externalVariantId),
'external_variant_id_match' => $this->normalizeNullableText($externalVariantId),
]);
$row = $statement->fetch();
if (!is_array($row)) {
return null;
}
return $this->mapLinkRow($row);
}
public function insertLink(
int $productId,
int $channelId,
?int $integrationId,
string $externalProductId,
?string $externalVariantId,
string $syncState,
string $linkType,
string $linkStatus,
?int $confidence,
?int $linkedByUserId,
?string $syncMetaJson
): int {
$now = date('Y-m-d H:i:s');
$statement = $this->pdo->prepare(
'INSERT INTO product_channel_map (
product_id, channel_id, integration_id, external_product_id, external_variant_id,
sync_state, link_type, link_status, confidence,
linked_at, linked_by_user_id, sync_meta_json, last_sync_at, created_at, updated_at
) VALUES (
:product_id, :channel_id, :integration_id, :external_product_id, :external_variant_id,
:sync_state, :link_type, :link_status, :confidence,
:linked_at, :linked_by_user_id, :sync_meta_json, :last_sync_at, :created_at, :updated_at
)'
);
$statement->execute([
'product_id' => $productId,
'channel_id' => $channelId,
'integration_id' => $integrationId,
'external_product_id' => trim($externalProductId),
'external_variant_id' => $this->normalizeNullableText($externalVariantId),
'sync_state' => trim($syncState),
'link_type' => trim($linkType),
'link_status' => trim($linkStatus),
'confidence' => $confidence,
'linked_at' => $now,
'linked_by_user_id' => $linkedByUserId,
'sync_meta_json' => $this->normalizeJson($syncMetaJson),
'last_sync_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
return (int) $this->pdo->lastInsertId();
}
public function updateLink(
int $mapId,
?int $integrationId,
string $externalProductId,
?string $externalVariantId,
string $syncState,
string $linkType,
string $linkStatus,
?int $confidence,
?int $linkedByUserId,
?string $syncMetaJson
): void {
$statement = $this->pdo->prepare(
'UPDATE product_channel_map SET
integration_id = :integration_id,
external_product_id = :external_product_id,
external_variant_id = :external_variant_id,
sync_state = :sync_state,
link_type = :link_type,
link_status = :link_status,
confidence = :confidence,
linked_at = :linked_at,
linked_by_user_id = :linked_by_user_id,
unlinked_at = NULL,
unlinked_by_user_id = NULL,
sync_meta_json = :sync_meta_json,
last_sync_at = :last_sync_at,
updated_at = :updated_at
WHERE id = :id'
);
$now = date('Y-m-d H:i:s');
$statement->execute([
'id' => $mapId,
'integration_id' => $integrationId,
'external_product_id' => trim($externalProductId),
'external_variant_id' => $this->normalizeNullableText($externalVariantId),
'sync_state' => trim($syncState),
'link_type' => trim($linkType),
'link_status' => trim($linkStatus),
'confidence' => $confidence,
'linked_at' => $now,
'linked_by_user_id' => $linkedByUserId,
'sync_meta_json' => $this->normalizeJson($syncMetaJson),
'last_sync_at' => $now,
'updated_at' => $now,
]);
}
public function markAsUnlinked(
int $mapId,
?int $userId,
string $linkStatus = 'inactive',
string $syncState = 'unlinked'
): void
{
$statement = $this->pdo->prepare(
'UPDATE product_channel_map SET
sync_state = :sync_state,
link_status = :link_status,
unlinked_at = :unlinked_at,
unlinked_by_user_id = :unlinked_by_user_id,
updated_at = :updated_at
WHERE id = :id'
);
$now = date('Y-m-d H:i:s');
$statement->execute([
'id' => $mapId,
'sync_state' => trim($syncState),
'link_status' => trim($linkStatus),
'unlinked_at' => $now,
'unlinked_by_user_id' => $userId,
'updated_at' => $now,
]);
}
public function deleteById(int $id): bool
{
$statement = $this->pdo->prepare('DELETE FROM product_channel_map WHERE id = :id LIMIT 1');
$statement->execute(['id' => $id]);
return $statement->rowCount() > 0;
}
/**
* @return array<int, array<string, mixed>>
*/
public function findActiveByExternalIdentity(
int $integrationId,
string $externalProductId,
?string $externalVariantId
): array {
$statement = $this->pdo->prepare(
'SELECT id, product_id, channel_id, integration_id, external_product_id, external_variant_id,
sync_state, link_type, link_status, confidence, linked_at, linked_by_user_id,
unlinked_at, unlinked_by_user_id, sync_meta_json, last_sync_at, created_at, updated_at
FROM product_channel_map
WHERE integration_id = :integration_id
AND external_product_id = :external_product_id
AND (
(:external_variant_id_value IS NULL AND external_variant_id IS NULL)
OR external_variant_id = :external_variant_id_match
)
AND link_status = :link_status
ORDER BY id DESC'
);
$statement->execute([
'integration_id' => $integrationId,
'external_product_id' => trim($externalProductId),
'external_variant_id_value' => $this->normalizeNullableText($externalVariantId),
'external_variant_id_match' => $this->normalizeNullableText($externalVariantId),
'link_status' => 'active',
]);
$rows = $statement->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map([$this, 'mapLinkRow'], $rows);
}
public function logEvent(
int $productChannelMapId,
string $eventType,
?array $before,
?array $after,
?int $createdByUserId
): int {
$statement = $this->pdo->prepare(
'INSERT INTO product_link_events (
product_channel_map_id, event_type, before_json, after_json, created_by_user_id, created_at
) VALUES (
:product_channel_map_id, :event_type, :before_json, :after_json, :created_by_user_id, :created_at
)'
);
$beforeJson = $before === null ? null : json_encode($before, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$afterJson = $after === null ? null : json_encode($after, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$statement->execute([
'product_channel_map_id' => $productChannelMapId,
'event_type' => trim($eventType),
'before_json' => $beforeJson === false ? null : $beforeJson,
'after_json' => $afterJson === false ? null : $afterJson,
'created_by_user_id' => $createdByUserId,
'created_at' => date('Y-m-d H:i:s'),
]);
return (int) $this->pdo->lastInsertId();
}
/**
* @return array<int, array<string, mixed>>
*/
public function listEventsByMapId(int $productChannelMapId, int $limit = 50): array
{
$statement = $this->pdo->prepare(
'SELECT id, product_channel_map_id, event_type, before_json, after_json, created_by_user_id, created_at
FROM product_link_events
WHERE product_channel_map_id = :product_channel_map_id
ORDER BY id DESC
LIMIT :limit'
);
$statement->bindValue(':product_channel_map_id', $productChannelMapId, PDO::PARAM_INT);
$statement->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
$statement->execute();
$rows = $statement->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map([$this, 'mapEventRow'], $rows);
}
/**
* @return array<int, array<string, mixed>>
*/
public function listActiveLinksForMissingCheck(?int $integrationId = null): array
{
$sql = 'SELECT id, product_id, channel_id, integration_id, external_product_id, external_variant_id,
sync_state, link_type, link_status, confidence, linked_at, linked_by_user_id,
unlinked_at, unlinked_by_user_id, sync_meta_json, last_sync_at, created_at, updated_at
FROM product_channel_map
WHERE link_status = :link_status
AND integration_id IS NOT NULL
AND external_product_id IS NOT NULL
AND external_product_id <> \'\'
AND unlinked_at IS NULL';
$params = [
'link_status' => 'active',
];
if ($integrationId !== null && $integrationId > 0) {
$sql .= ' AND integration_id = :integration_id';
$params['integration_id'] = $integrationId;
}
$sql .= ' ORDER BY id ASC';
$statement = $this->pdo->prepare($sql);
$statement->execute($params);
$rows = $statement->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map([$this, 'mapLinkRow'], $rows);
}
public function upsertActiveAlert(
int $productChannelMapId,
string $alertType,
string $message,
string $detectedAt
): void {
$statement = $this->pdo->prepare(
'INSERT INTO product_link_alerts (
product_channel_map_id, alert_type, status, message,
first_detected_at, last_detected_at, resolved_at, created_at, updated_at
) VALUES (
:product_channel_map_id, :alert_type, :status, :message,
:first_detected_at, :last_detected_at, NULL, :created_at, :updated_at
) ON DUPLICATE KEY UPDATE
status = VALUES(status),
message = VALUES(message),
last_detected_at = VALUES(last_detected_at),
resolved_at = NULL,
updated_at = VALUES(updated_at)'
);
$statement->execute([
'product_channel_map_id' => $productChannelMapId,
'alert_type' => trim($alertType),
'status' => 'active',
'message' => mb_substr(trim($message), 0, 255),
'first_detected_at' => $detectedAt,
'last_detected_at' => $detectedAt,
'created_at' => $detectedAt,
'updated_at' => $detectedAt,
]);
}
public function resolveActiveAlert(
int $productChannelMapId,
string $alertType,
string $resolvedAt
): void {
$statement = $this->pdo->prepare(
'UPDATE product_link_alerts SET
status = :status,
resolved_at = :resolved_at,
updated_at = :updated_at
WHERE product_channel_map_id = :product_channel_map_id
AND alert_type = :alert_type
AND status = :current_status'
);
$statement->execute([
'product_channel_map_id' => $productChannelMapId,
'alert_type' => trim($alertType),
'status' => 'resolved',
'current_status' => 'active',
'resolved_at' => $resolvedAt,
'updated_at' => $resolvedAt,
]);
}
private function normalizeNullableText(?string $value): ?string
{
if ($value === null) {
return null;
}
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function normalizeJson(?string $json): ?string
{
if ($json === null) {
return null;
}
$trimmed = trim($json);
if ($trimmed === '') {
return null;
}
return $trimmed;
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function mapLinkRow(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'product_id' => (int) ($row['product_id'] ?? 0),
'channel_id' => (int) ($row['channel_id'] ?? 0),
'integration_id' => $row['integration_id'] === null ? null : (int) $row['integration_id'],
'external_product_id' => (string) ($row['external_product_id'] ?? ''),
'external_variant_id' => isset($row['external_variant_id']) ? (string) $row['external_variant_id'] : null,
'sync_state' => (string) ($row['sync_state'] ?? ''),
'link_type' => (string) ($row['link_type'] ?? 'manual'),
'link_status' => (string) ($row['link_status'] ?? 'active'),
'confidence' => $row['confidence'] === null ? null : (int) $row['confidence'],
'linked_at' => isset($row['linked_at']) ? (string) $row['linked_at'] : null,
'linked_by_user_id' => $row['linked_by_user_id'] === null ? null : (int) $row['linked_by_user_id'],
'unlinked_at' => isset($row['unlinked_at']) ? (string) $row['unlinked_at'] : null,
'unlinked_by_user_id' => $row['unlinked_by_user_id'] === null ? null : (int) $row['unlinked_by_user_id'],
'sync_meta_json' => isset($row['sync_meta_json']) ? (string) $row['sync_meta_json'] : null,
'last_sync_at' => isset($row['last_sync_at']) ? (string) $row['last_sync_at'] : null,
'channel_code' => isset($row['channel_code']) ? (string) $row['channel_code'] : '',
'channel_name' => isset($row['channel_name']) ? (string) $row['channel_name'] : '',
'integration_name' => isset($row['integration_name']) ? (string) $row['integration_name'] : '',
'has_missing_alert' => isset($row['missing_alert_first_detected_at']) && (string) $row['missing_alert_first_detected_at'] !== '',
'missing_alert_message' => isset($row['missing_alert_message']) ? (string) $row['missing_alert_message'] : null,
'missing_alert_first_detected_at' => isset($row['missing_alert_first_detected_at']) ? (string) $row['missing_alert_first_detected_at'] : null,
'missing_alert_last_detected_at' => isset($row['missing_alert_last_detected_at']) ? (string) $row['missing_alert_last_detected_at'] : null,
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function mapEventRow(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'product_channel_map_id' => (int) ($row['product_channel_map_id'] ?? 0),
'event_type' => (string) ($row['event_type'] ?? ''),
'before_json' => isset($row['before_json']) ? (string) $row['before_json'] : null,
'after_json' => isset($row['after_json']) ? (string) $row['after_json'] : null,
'created_by_user_id' => $row['created_by_user_id'] === null ? null : (int) $row['created_by_user_id'],
'created_at' => (string) ($row['created_at'] ?? ''),
];
}
}

View File

@@ -1,453 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\ProductLinks;
use App\Modules\Settings\IntegrationRepository;
use PDO;
use Throwable;
final class ProductLinksService
{
public function __construct(
private readonly ProductLinksRepository $links,
private readonly ChannelOffersRepository $offers,
private readonly IntegrationRepository $integrations,
private readonly LinkMatcherService $matcher,
private readonly PDO $pdo
) {
}
/**
* @param array<string, mixed> $product
* @return array{
* links:array<int, array<string, mixed>>,
* link_events_by_map:array<int, array<int, array<string, mixed>>>,
* integrations:array<int, array<string, mixed>>,
* selected_integration_id:int,
* search_query:string,
* offers:array<int, array<string, mixed>>
* }
*/
public function buildProductLinksViewData(
int $productId,
array $product,
int $selectedIntegrationId,
string $searchQuery
): array {
$links = array_values(array_filter(
$this->links->listByProductId($productId),
static function (array $link): bool {
$integrationId = (int) ($link['integration_id'] ?? 0);
return $integrationId > 0;
}
));
$linkEventsByMap = [];
foreach ($links as $link) {
$mapId = (int) ($link['id'] ?? 0);
if ($mapId <= 0) {
continue;
}
$linkEventsByMap[$mapId] = $this->links->listEventsByMapId($mapId, 10);
}
$integrations = array_values(array_filter(
$this->integrations->listByType('shoppro'),
static fn (array $row): bool => ($row['is_active'] ?? false) === true
&& ($row['has_api_key'] ?? false) === true
));
$effectiveIntegrationId = $selectedIntegrationId;
if ($effectiveIntegrationId <= 0 && $integrations !== []) {
$effectiveIntegrationId = (int) ($integrations[0]['id'] ?? 0);
}
$query = trim($searchQuery);
$offers = [];
if ($effectiveIntegrationId > 0) {
if ($query !== '') {
$offers = $this->offers->search($effectiveIntegrationId, null, $query, 30);
} else {
$offers = $this->offerSuggestions($effectiveIntegrationId, $product);
}
}
return [
'links' => $links,
'link_events_by_map' => $linkEventsByMap,
'integrations' => $integrations,
'selected_integration_id' => $effectiveIntegrationId,
'search_query' => $query,
'offers' => $this->decorateOffersWithMatchHint($offers, $product),
];
}
/**
* @return array{ok:bool,conflict:bool,message:string}
*/
public function createManualLink(
int $productId,
int $integrationId,
string $externalProductId,
?string $externalVariantId,
?int $userId
): array {
return $this->upsertManualLink(
$productId,
null,
$integrationId,
$externalProductId,
$externalVariantId,
$userId
);
}
/**
* @return array{ok:bool,conflict:bool,message:string}
*/
public function relink(
int $productId,
int $mapId,
int $integrationId,
string $externalProductId,
?string $externalVariantId,
?int $userId
): array {
$existingMap = $this->links->findById($mapId);
if ($existingMap === null || (int) ($existingMap['product_id'] ?? 0) !== $productId) {
return [
'ok' => false,
'conflict' => false,
'message' => 'Nie znaleziono wskazanego powiazania produktu.',
];
}
return $this->upsertManualLink(
$productId,
$mapId,
$integrationId,
$externalProductId,
$externalVariantId,
$userId
);
}
/**
* @return array{ok:bool,message:string}
*/
public function unlink(int $productId, int $mapId, ?int $userId): array
{
$existingMap = $this->links->findById($mapId);
if ($existingMap === null || (int) ($existingMap['product_id'] ?? 0) !== $productId) {
return [
'ok' => false,
'message' => 'Nie znaleziono wskazanego powiazania produktu.',
];
}
try {
$this->pdo->beginTransaction();
$deleted = $this->links->deleteById($mapId);
if (!$deleted) {
throw new \RuntimeException('Nie udalo sie usunac wskazanego powiazania.');
}
if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
} catch (Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
return [
'ok' => false,
'message' => $exception->getMessage(),
];
}
return [
'ok' => true,
'message' => '',
];
}
/**
* @return array{ok:bool,conflict:bool,message:string}
*/
private function upsertManualLink(
int $productId,
?int $mapId,
int $integrationId,
string $externalProductId,
?string $externalVariantId,
?int $userId
): array {
$normalizedExternalProductId = trim($externalProductId);
$normalizedExternalVariantId = $this->nullableText($externalVariantId);
if ($integrationId <= 0) {
return [
'ok' => false,
'conflict' => false,
'message' => 'Wybierz poprawna integracje.',
];
}
if ($normalizedExternalProductId === '') {
return [
'ok' => false,
'conflict' => false,
'message' => 'Podaj poprawne external_product_id.',
];
}
$offer = $this->offers->findByExternalIdentity(
$integrationId,
$normalizedExternalProductId,
$normalizedExternalVariantId
);
if ($offer === null) {
return [
'ok' => false,
'conflict' => false,
'message' => 'Nie znaleziono oferty w lokalnym cache. Najpierw wykonaj import ofert.',
];
}
$channelId = (int) ($offer['channel_id'] ?? 0);
if ($channelId <= 0) {
return [
'ok' => false,
'conflict' => false,
'message' => 'Oferta ma niepoprawny channel_id.',
];
}
$syncMetaJson = json_encode([
'offer_name' => (string) ($offer['name'] ?? ''),
'external_offer_id' => $offer['external_offer_id'] ?? null,
'offer_status' => (string) ($offer['offer_status'] ?? ''),
'source' => 'channel_offers',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($syncMetaJson === false) {
$syncMetaJson = null;
}
$activeMatches = $this->links->findActiveByExternalIdentity(
$integrationId,
$normalizedExternalProductId,
$normalizedExternalVariantId
);
$conflictingActive = null;
foreach ($activeMatches as $match) {
if ($mapId !== null && (int) ($match['id'] ?? 0) === $mapId) {
continue;
}
if ((int) ($match['product_id'] ?? 0) !== $productId) {
$conflictingActive = $match;
break;
}
}
$targetMap = $mapId === null
? $this->links->findByProductAndIdentity(
$productId,
$integrationId,
$normalizedExternalProductId,
$normalizedExternalVariantId
)
: $this->links->findById($mapId);
try {
$this->pdo->beginTransaction();
if ($conflictingActive !== null) {
$targetMapId = $targetMap === null
? $this->links->insertLink(
$productId,
$channelId,
$integrationId,
$normalizedExternalProductId,
$normalizedExternalVariantId,
'conflict',
'manual',
'conflict',
null,
$userId,
$syncMetaJson
)
: (int) ($targetMap['id'] ?? 0);
if ($targetMap !== null) {
$this->links->updateLink(
$targetMapId,
$integrationId,
$normalizedExternalProductId,
$normalizedExternalVariantId,
'conflict',
'manual',
'conflict',
null,
$userId,
$syncMetaJson
);
}
$afterConflict = $this->links->findById($targetMapId);
$this->links->logEvent(
$targetMapId,
'conflict_detected',
$targetMap,
$afterConflict,
$userId
);
if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
return [
'ok' => false,
'conflict' => true,
'message' => 'Wykryto konflikt: oferta jest aktywnie powiazana z innym produktem.',
];
}
if ($targetMap === null) {
$targetMapId = $this->links->insertLink(
$productId,
$channelId,
$integrationId,
$normalizedExternalProductId,
$normalizedExternalVariantId,
'linked',
'manual',
'active',
null,
$userId,
$syncMetaJson
);
$after = $this->links->findById($targetMapId);
$this->links->logEvent($targetMapId, 'linked', null, $after, $userId);
} else {
$targetMapId = (int) ($targetMap['id'] ?? 0);
$this->links->updateLink(
$targetMapId,
$integrationId,
$normalizedExternalProductId,
$normalizedExternalVariantId,
'linked',
'manual',
'active',
null,
$userId,
$syncMetaJson
);
$after = $this->links->findById($targetMapId);
$eventType = $mapId === null ? 'linked' : 'relinked';
$this->links->logEvent($targetMapId, $eventType, $targetMap, $after, $userId);
}
if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
} catch (Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
return [
'ok' => false,
'conflict' => false,
'message' => $exception->getMessage(),
];
}
return [
'ok' => true,
'conflict' => false,
'message' => '',
];
}
/**
* @param array<string, mixed> $product
* @return array<int, array<string, mixed>>
*/
private function offerSuggestions(int $integrationId, array $product): array
{
$sku = trim((string) ($product['sku'] ?? ''));
$ean = trim((string) ($product['ean'] ?? ''));
$combined = [];
if ($ean !== '') {
foreach ($this->offers->search($integrationId, null, $ean, 15) as $row) {
$combined[$this->offerKey($row)] = $row;
}
}
if ($sku !== '') {
foreach ($this->offers->search($integrationId, null, $sku, 15) as $row) {
$combined[$this->offerKey($row)] = $row;
}
$normalizedSku = preg_replace('/[\s\-_]+/u', '', $sku) ?? '';
if ($normalizedSku !== '' && $normalizedSku !== $sku) {
foreach ($this->offers->search($integrationId, null, $normalizedSku, 15) as $row) {
$combined[$this->offerKey($row)] = $row;
}
}
}
$offers = $this->decorateOffersWithMatchHint(array_values($combined), $product);
usort($offers, static function (array $a, array $b): int {
$left = (int) ($a['match_confidence'] ?? 0);
$right = (int) ($b['match_confidence'] ?? 0);
if ($left === $right) {
return strcmp((string) ($b['last_seen_at'] ?? ''), (string) ($a['last_seen_at'] ?? ''));
}
return $right <=> $left;
});
return $offers;
}
/**
* @param array<int, array<string, mixed>> $offers
* @param array<string, mixed> $product
* @return array<int, array<string, mixed>>
*/
private function decorateOffersWithMatchHint(array $offers, array $product): array
{
return array_map(function (array $offer) use ($product): array {
$match = $this->matcher->match($product, $offer);
$offer['match_hint'] = (string) ($match['match_hint'] ?? '');
$offer['match_confidence'] = (int) ($match['confidence'] ?? 0);
$offer['suggested_link_type'] = (string) ($match['link_type'] ?? 'manual');
return $offer;
}, $offers);
}
/**
* @param array<string, mixed> $offer
*/
private function offerKey(array $offer): string
{
return (string) ($offer['integration_id'] ?? 0)
. '|'
. (string) ($offer['external_product_id'] ?? '')
. '|'
. (string) ($offer['external_variant_id'] ?? '');
}
private function nullableText(?string $value): ?string
{
if ($value === null) {
return null;
}
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
}

View File

@@ -1,751 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Products;
use PDO;
final class ProductRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @param array<string, mixed> $filters
* @return array{items:array<int, array<string, mixed>>, total:int, page:int, per_page:int}
*/
public function paginate(array $filters, string $lang = 'pl'): array
{
$page = max(1, (int) ($filters['page'] ?? 1));
$perPage = max(1, min(100, (int) ($filters['per_page'] ?? 20)));
$offset = ($page - 1) * $perPage;
[$whereSql, $params] = $this->buildFilters($filters);
$sort = $this->resolveSort((string) ($filters['sort'] ?? 'id'));
$sortDir = strtoupper((string) ($filters['sort_dir'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
$countStmt = $this->pdo->prepare(
'SELECT COUNT(*)
FROM products p
LEFT JOIN product_translations pt ON pt.product_id = p.id AND pt.lang = :lang_count
' . $whereSql
);
$countStmt->execute(array_merge(['lang_count' => $lang], $params));
$total = (int) $countStmt->fetchColumn();
$listStmt = $this->pdo->prepare(
'SELECT p.id, p.type, p.sku, p.ean, p.status, p.promoted, p.price_brutto, p.quantity, p.updated_at, p.created_at,
COALESCE(pt.name, "") AS name,
(
SELECT pi.storage_path
FROM product_images pi
WHERE pi.product_id = p.id
ORDER BY pi.is_main DESC, pi.sort_order ASC, pi.id ASC
LIMIT 1
) AS main_image_path
FROM products p
LEFT JOIN product_translations pt ON pt.product_id = p.id AND pt.lang = :lang_list
' . $whereSql . '
ORDER BY ' . $sort . ' ' . $sortDir . '
LIMIT :limit OFFSET :offset'
);
foreach (array_merge(['lang_list' => $lang], $params) as $key => $value) {
$listStmt->bindValue(':' . $key, $value);
}
$listStmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$listStmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$listStmt->execute();
$rows = $listStmt->fetchAll();
if (!is_array($rows)) {
$rows = [];
}
return [
'items' => array_map([$this, 'mapListRow'], $rows),
'total' => $total,
'page' => $page,
'per_page' => $perPage,
];
}
/**
* @return array<string, mixed>|null
*/
public function findById(int $id, string $lang = 'pl'): ?array
{
$stmt = $this->pdo->prepare(
'SELECT p.*, pt.name, pt.short_description, pt.description, pt.meta_title,
pt.meta_description, pt.meta_keywords, pt.seo_link, pt.security_information,
p.producer_name
FROM products p
LEFT JOIN product_translations pt ON pt.product_id = p.id AND pt.lang = :lang
WHERE p.id = :id
LIMIT 1'
);
$stmt->execute([
'id' => $id,
'lang' => $lang,
]);
$row = $stmt->fetch();
if (!is_array($row)) {
return null;
}
return $this->mapDetailsRow($row);
}
public function existsSku(string $sku, ?int $excludeProductId = null): bool
{
$normalized = trim($sku);
if ($normalized === '') {
return false;
}
$sql = 'SELECT 1 FROM products WHERE sku = :sku';
$params = ['sku' => $normalized];
if ($excludeProductId !== null) {
$sql .= ' AND id <> :exclude_id';
$params['exclude_id'] = $excludeProductId;
}
$sql .= ' LIMIT 1';
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchColumn() !== false;
}
/**
* @return array<int, string>
*/
public function findAllSkus(): array
{
$stmt = $this->pdo->query(
'SELECT sku
FROM products
WHERE sku IS NOT NULL
AND sku <> ""
AND deleted_at IS NULL'
);
$rows = $stmt === false ? [] : $stmt->fetchAll(PDO::FETCH_COLUMN);
if (!is_array($rows)) {
return [];
}
return array_values(array_filter(array_map(
static fn (mixed $value): string => trim((string) $value),
$rows
), static fn (string $sku): bool => $sku !== ''));
}
public function findIdBySku(string $sku): ?int
{
$normalized = trim($sku);
if ($normalized === '') {
return null;
}
$stmt = $this->pdo->prepare('SELECT id FROM products WHERE sku = :sku LIMIT 1');
$stmt->execute(['sku' => $normalized]);
$value = $stmt->fetchColumn();
return $value === false ? null : (int) $value;
}
public function findIdByEan(string $ean): ?int
{
$normalized = trim($ean);
if ($normalized === '') {
return null;
}
$stmt = $this->pdo->prepare('SELECT id FROM products WHERE ean = :ean LIMIT 1');
$stmt->execute(['ean' => $normalized]);
$value = $stmt->fetchColumn();
return $value === false ? null : (int) $value;
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $translation
*/
public function create(array $payload, array $translation): int
{
$stmt = $this->pdo->prepare(
'INSERT INTO products (
uuid, type, sku, ean, status, promoted, new_to_date,
additional_message, additional_message_required, additional_message_text,
vat, weight,
price_brutto, price_brutto_promo, price_netto, price_netto_promo,
quantity, producer_id, producer_name, product_unit_id, custom_fields_json, created_at, updated_at
) VALUES (
:uuid, :type, :sku, :ean, :status, :promoted, :new_to_date,
:additional_message, :additional_message_required, :additional_message_text,
:vat, :weight,
:price_brutto, :price_brutto_promo, :price_netto, :price_netto_promo,
:quantity, :producer_id, :producer_name, :product_unit_id, :custom_fields_json, :created_at, :updated_at
)'
);
$stmt->execute($payload);
$productId = (int) $this->pdo->lastInsertId();
$translationStmt = $this->pdo->prepare(
'INSERT INTO product_translations (
product_id, lang, name, short_description, description,
meta_title, meta_description, meta_keywords, seo_link, security_information, created_at, updated_at
) VALUES (
:product_id, :lang, :name, :short_description, :description,
:meta_title, :meta_description, :meta_keywords, :seo_link, :security_information, :created_at, :updated_at
)'
);
$translationStmt->execute(array_merge(['product_id' => $productId], $translation));
return $productId;
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $translation
*/
public function update(int $id, array $payload, array $translation): void
{
$stmt = $this->pdo->prepare(
'UPDATE products SET
type = :type,
sku = :sku,
ean = :ean,
status = :status,
promoted = :promoted,
new_to_date = :new_to_date,
additional_message = :additional_message,
additional_message_required = :additional_message_required,
additional_message_text = :additional_message_text,
vat = :vat,
weight = :weight,
price_brutto = :price_brutto,
price_brutto_promo = :price_brutto_promo,
price_netto = :price_netto,
price_netto_promo = :price_netto_promo,
quantity = :quantity,
producer_id = :producer_id,
producer_name = :producer_name,
product_unit_id = :product_unit_id,
custom_fields_json = :custom_fields_json,
updated_at = :updated_at
WHERE id = :id'
);
$stmt->execute(array_merge($payload, ['id' => $id]));
$translationUpsert = $this->pdo->prepare(
'INSERT INTO product_translations (
product_id, lang, name, short_description, description,
meta_title, meta_description, meta_keywords, seo_link, security_information, created_at, updated_at
) VALUES (
:product_id, :lang, :name, :short_description, :description,
:meta_title, :meta_description, :meta_keywords, :seo_link, :security_information, :created_at, :updated_at
) ON DUPLICATE KEY UPDATE
name = VALUES(name),
short_description = VALUES(short_description),
description = VALUES(description),
meta_title = VALUES(meta_title),
meta_description = VALUES(meta_description),
meta_keywords = VALUES(meta_keywords),
seo_link = VALUES(seo_link),
security_information = VALUES(security_information),
updated_at = VALUES(updated_at)'
);
$translationUpsert->execute(array_merge(['product_id' => $id], $translation));
}
public function deleteById(int $id): bool
{
$stmt = $this->pdo->prepare('DELETE FROM products WHERE id = :id LIMIT 1');
$stmt->execute(['id' => $id]);
return $stmt->rowCount() > 0;
}
/**
* @return array<int, array<string, mixed>>
*/
public function findImagesByProductId(int $productId): array
{
$stmt = $this->pdo->prepare(
'SELECT id, product_id, storage_path, alt, sort_order, is_main, created_at, updated_at
FROM product_images
WHERE product_id = :product_id
ORDER BY is_main DESC, sort_order ASC, id ASC'
);
$stmt->execute(['product_id' => $productId]);
$rows = $stmt->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map(static function (array $row): array {
return [
'id' => (int) ($row['id'] ?? 0),
'product_id' => (int) ($row['product_id'] ?? 0),
'storage_path' => (string) ($row['storage_path'] ?? ''),
'alt' => isset($row['alt']) ? (string) $row['alt'] : null,
'sort_order' => (int) ($row['sort_order'] ?? 0),
'is_main' => (int) ($row['is_main'] ?? 0),
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}, $rows);
}
public function existsVariantSku(string $sku, ?int $excludeProductId = null): bool
{
$normalized = trim($sku);
if ($normalized === '') {
return false;
}
$sql = 'SELECT 1
FROM product_variants pv
INNER JOIN products p ON p.id = pv.product_id
WHERE pv.sku = :sku
AND p.deleted_at IS NULL';
$params = ['sku' => $normalized];
if ($excludeProductId !== null && $excludeProductId > 0) {
$sql .= ' AND pv.product_id <> :exclude_product_id';
$params['exclude_product_id'] = $excludeProductId;
}
$sql .= ' LIMIT 1';
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchColumn() !== false;
}
/**
* @return array<int, array<string, mixed>>
*/
public function findVariantsByProductId(int $productId, string $lang = 'pl'): array
{
$variantStmt = $this->pdo->prepare(
'SELECT id, product_id, permutation_hash, sku, ean, status,
price_brutto, price_brutto_promo, price_netto, price_netto_promo, weight, stock_0_buy,
created_at, updated_at
FROM product_variants
WHERE product_id = :product_id
ORDER BY id ASC'
);
$variantStmt->execute(['product_id' => $productId]);
$variantRows = $variantStmt->fetchAll();
if (!is_array($variantRows) || $variantRows === []) {
return [];
}
$variants = [];
foreach ($variantRows as $row) {
if (!is_array($row)) {
continue;
}
$id = (int) ($row['id'] ?? 0);
if ($id <= 0) {
continue;
}
$variants[$id] = [
'id' => $id,
'product_id' => (int) ($row['product_id'] ?? 0),
'permutation_hash' => (string) ($row['permutation_hash'] ?? ''),
'sku' => isset($row['sku']) ? (string) $row['sku'] : null,
'ean' => isset($row['ean']) ? (string) $row['ean'] : null,
'status' => (int) ($row['status'] ?? 0),
'price_brutto' => $row['price_brutto'] === null ? null : (float) $row['price_brutto'],
'price_brutto_promo' => $row['price_brutto_promo'] === null ? null : (float) $row['price_brutto_promo'],
'price_netto' => $row['price_netto'] === null ? null : (float) $row['price_netto'],
'price_netto_promo' => $row['price_netto_promo'] === null ? null : (float) $row['price_netto_promo'],
'weight' => $row['weight'] === null ? null : (float) $row['weight'],
'stock_0_buy' => (int) ($row['stock_0_buy'] ?? 0),
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
'attributes' => [],
];
}
if ($variants === []) {
return [];
}
$attributeStmt = $this->pdo->prepare(
'SELECT pva.variant_id, pva.attribute_id, pva.value_id,
COALESCE(a.type, 0) AS attribute_type,
COALESCE(at.name, CONCAT("Atrybut #", pva.attribute_id)) AS attribute_name,
COALESCE(avt.name, CONCAT("Wartosc #", pva.value_id)) AS value_name
FROM product_variant_attributes pva
LEFT JOIN attributes a ON a.id = pva.attribute_id
LEFT JOIN attribute_translations at ON at.attribute_id = pva.attribute_id AND at.lang = :lang_attr
LEFT JOIN attribute_value_translations avt ON avt.value_id = pva.value_id AND avt.lang = :lang_value
WHERE pva.variant_id IN (' . implode(',', array_map('intval', array_keys($variants))) . ')
ORDER BY pva.variant_id ASC, pva.attribute_id ASC'
);
$attributeStmt->execute([
'lang_attr' => $lang,
'lang_value' => $lang,
]);
$attributeRows = $attributeStmt->fetchAll();
if (is_array($attributeRows)) {
foreach ($attributeRows as $row) {
if (!is_array($row)) {
continue;
}
$variantId = (int) ($row['variant_id'] ?? 0);
if ($variantId <= 0 || !isset($variants[$variantId])) {
continue;
}
$variants[$variantId]['attributes'][] = [
'attribute_id' => (int) ($row['attribute_id'] ?? 0),
'value_id' => (int) ($row['value_id'] ?? 0),
'attribute_type' => (int) ($row['attribute_type'] ?? 0),
'attribute_name' => (string) ($row['attribute_name'] ?? ''),
'value_name' => (string) ($row['value_name'] ?? ''),
];
}
}
return array_values($variants);
}
/**
* @return array<string, mixed>|null
*/
public function findLatestImportWarning(int $productId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT change_type, after_json, created_at
FROM product_change_log
WHERE product_id = :product_id
AND change_type IN ("product_import_warning", "product_import_warning_clear")
ORDER BY id DESC
LIMIT 1'
);
$stmt->execute(['product_id' => $productId]);
$row = $stmt->fetch();
if (!is_array($row)) {
return null;
}
if ((string) ($row['change_type'] ?? '') === 'product_import_warning_clear') {
return null;
}
$payloadRaw = $row['after_json'] ?? null;
$payload = null;
if (is_string($payloadRaw) && trim($payloadRaw) !== '') {
$decoded = json_decode($payloadRaw, true);
if (is_array($decoded)) {
$payload = $decoded;
}
} elseif (is_array($payloadRaw)) {
$payload = $payloadRaw;
}
if (!is_array($payload)) {
return null;
}
return [
'created_at' => (string) ($row['created_at'] ?? ''),
'title' => (string) ($payload['title'] ?? ''),
'messages' => array_values(array_filter(
(array) ($payload['messages'] ?? []),
static fn (mixed $item): bool => is_string($item) && trim($item) !== ''
)),
];
}
public function createImage(int $productId, string $storagePath, ?string $alt, int $sortOrder, int $isMain): int
{
$stmt = $this->pdo->prepare(
'INSERT INTO product_images (product_id, storage_path, alt, sort_order, is_main, created_at, updated_at)
VALUES (:product_id, :storage_path, :alt, :sort_order, :is_main, :created_at, :updated_at)'
);
$now = date('Y-m-d H:i:s');
$stmt->execute([
'product_id' => $productId,
'storage_path' => trim($storagePath),
'alt' => $alt,
'sort_order' => $sortOrder,
'is_main' => $isMain === 1 ? 1 : 0,
'created_at' => $now,
'updated_at' => $now,
]);
return (int) $this->pdo->lastInsertId();
}
public function deleteImageById(int $productId, int $imageId): ?string
{
$find = $this->pdo->prepare(
'SELECT storage_path
FROM product_images
WHERE id = :id AND product_id = :product_id
LIMIT 1'
);
$find->execute(['id' => $imageId, 'product_id' => $productId]);
$path = $find->fetchColumn();
if (!is_string($path) || trim($path) === '') {
return null;
}
$delete = $this->pdo->prepare(
'DELETE FROM product_images
WHERE id = :id AND product_id = :product_id
LIMIT 1'
);
$delete->execute(['id' => $imageId, 'product_id' => $productId]);
if ($delete->rowCount() <= 0) {
return null;
}
return trim($path);
}
public function setMainImage(int $productId, int $imageId): void
{
$reset = $this->pdo->prepare('UPDATE product_images SET is_main = 0 WHERE product_id = :product_id');
$reset->execute(['product_id' => $productId]);
$setMain = $this->pdo->prepare(
'UPDATE product_images
SET is_main = 1
WHERE product_id = :product_id AND id = :image_id
LIMIT 1'
);
$setMain->execute([
'product_id' => $productId,
'image_id' => $imageId,
]);
}
/**
* @param array<string, mixed>|null $before
* @param array<string, mixed>|null $after
*/
public function logChange(int $productId, ?int $userId, string $changeType, ?array $before, ?array $after): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO product_change_log (product_id, user_id, change_type, before_json, after_json, created_at)
VALUES (:product_id, :user_id, :change_type, :before_json, :after_json, :created_at)'
);
$beforeJson = $before === null ? null : json_encode($before, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$afterJson = $after === null ? null : json_encode($after, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$stmt->execute([
'product_id' => $productId,
'user_id' => $userId,
'change_type' => $changeType,
'before_json' => $beforeJson === false ? null : $beforeJson,
'after_json' => $afterJson === false ? null : $afterJson,
'created_at' => date('Y-m-d H:i:s'),
]);
}
/**
* @param array<string, mixed> $filters
* @return array{0:string,1:array<string,mixed>}
*/
private function buildFilters(array $filters): array
{
$where = ['p.deleted_at IS NULL'];
$params = [];
$search = trim((string) ($filters['search'] ?? ''));
if ($search !== '') {
$where[] = '(pt.name LIKE :search OR p.sku LIKE :search OR p.ean LIKE :search)';
$params['search'] = '%' . $search . '%';
}
$status = (string) ($filters['status'] ?? '');
if ($status !== '' && in_array($status, ['0', '1'], true)) {
$where[] = 'p.status = :status';
$params['status'] = (int) $status;
}
$type = (string) ($filters['type'] ?? '');
if ($type !== '' && in_array($type, ['simple', 'variant_parent'], true)) {
$where[] = 'p.type = :type';
$params['type'] = $type;
}
$whereSql = 'WHERE ' . implode(' AND ', $where);
return [$whereSql, $params];
}
private function resolveSort(string $sort): string
{
return match ($sort) {
'name' => 'pt.name',
'sku' => 'p.sku',
'ean' => 'p.ean',
'price_brutto' => 'p.price_brutto',
'quantity' => 'p.quantity',
'status' => 'p.status',
'updated_at' => 'p.updated_at',
'created_at' => 'p.created_at',
default => 'p.id',
};
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function mapListRow(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'main_image_path' => (string) ($row['main_image_path'] ?? ''),
'type' => (string) ($row['type'] ?? 'simple'),
'sku' => (string) ($row['sku'] ?? ''),
'ean' => (string) ($row['ean'] ?? ''),
'status' => (int) ($row['status'] ?? 0),
'promoted' => (int) ($row['promoted'] ?? 0),
'price_brutto' => (float) ($row['price_brutto'] ?? 0),
'quantity' => (float) ($row['quantity'] ?? 0),
'updated_at' => (string) ($row['updated_at'] ?? ''),
'created_at' => (string) ($row['created_at'] ?? ''),
];
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function mapDetailsRow(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'uuid' => (string) ($row['uuid'] ?? ''),
'type' => (string) ($row['type'] ?? 'simple'),
'sku' => (string) ($row['sku'] ?? ''),
'ean' => (string) ($row['ean'] ?? ''),
'status' => (int) ($row['status'] ?? 1),
'promoted' => (int) ($row['promoted'] ?? 0),
'new_to_date' => isset($row['new_to_date']) && $row['new_to_date'] !== null ? (string) $row['new_to_date'] : null,
'additional_message' => (int) ($row['additional_message'] ?? 0),
'additional_message_required' => (int) ($row['additional_message_required'] ?? 0),
'additional_message_text' => isset($row['additional_message_text']) ? (string) $row['additional_message_text'] : null,
'vat' => $row['vat'] === null ? null : (float) $row['vat'],
'weight' => $row['weight'] === null ? null : (float) $row['weight'],
'price_brutto' => $row['price_brutto'] === null ? null : (float) $row['price_brutto'],
'price_brutto_promo' => $row['price_brutto_promo'] === null ? null : (float) $row['price_brutto_promo'],
'price_netto' => $row['price_netto'] === null ? null : (float) $row['price_netto'],
'price_netto_promo' => $row['price_netto_promo'] === null ? null : (float) $row['price_netto_promo'],
'quantity' => (float) ($row['quantity'] ?? 0),
'producer_id' => $row['producer_id'] === null ? null : (int) $row['producer_id'],
'producer_name' => isset($row['producer_name']) && $row['producer_name'] !== null ? (string) $row['producer_name'] : null,
'product_unit_id' => $row['product_unit_id'] === null ? null : (int) $row['product_unit_id'],
'name' => (string) ($row['name'] ?? ''),
'short_description' => (string) ($row['short_description'] ?? ''),
'description' => (string) ($row['description'] ?? ''),
'meta_title' => (string) ($row['meta_title'] ?? ''),
'meta_description' => (string) ($row['meta_description'] ?? ''),
'meta_keywords' => (string) ($row['meta_keywords'] ?? ''),
'seo_link' => (string) ($row['seo_link'] ?? ''),
'security_information' => isset($row['security_information']) ? (string) $row['security_information'] : null,
'custom_fields_json' => isset($row['custom_fields_json']) ? (string) $row['custom_fields_json'] : null,
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}
public function updateEan(int $id, string $ean): void
{
$stmt = $this->pdo->prepare(
'UPDATE products SET ean = :ean, updated_at = :updated_at WHERE id = :id'
);
$stmt->execute([
'ean' => $ean,
'updated_at' => date('Y-m-d H:i:s'),
'id' => $id,
]);
}
/**
* @return array<int, array<string, mixed>>
*/
public function findIntegrationTranslations(int $productId): array
{
$stmt = $this->pdo->prepare(
'SELECT pit.id, pit.product_id, pit.integration_id,
pit.name, pit.short_description, pit.description,
i.name AS integration_name
FROM product_integration_translations pit
LEFT JOIN integrations i ON i.id = pit.integration_id
WHERE pit.product_id = :product_id
ORDER BY i.name ASC'
);
$stmt->execute(['product_id' => $productId]);
$rows = $stmt->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map(static fn (array $row): array => [
'id' => (int) ($row['id'] ?? 0),
'product_id' => (int) ($row['product_id'] ?? 0),
'integration_id' => (int) ($row['integration_id'] ?? 0),
'integration_name' => (string) ($row['integration_name'] ?? ''),
'name' => isset($row['name']) ? (string) $row['name'] : null,
'short_description' => isset($row['short_description']) ? (string) $row['short_description'] : null,
'description' => isset($row['description']) ? (string) $row['description'] : null,
], $rows);
}
public function upsertIntegrationTranslation(
int $productId,
int $integrationId,
?string $name,
?string $shortDescription,
?string $description
): void {
$now = date('Y-m-d H:i:s');
$stmt = $this->pdo->prepare(
'INSERT INTO product_integration_translations
(product_id, integration_id, name, short_description, description, created_at, updated_at)
VALUES
(:product_id, :integration_id, :name, :short_description, :description, :created_at, :updated_at)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
short_description = VALUES(short_description),
description = VALUES(description),
updated_at = VALUES(updated_at)'
);
$stmt->execute([
'product_id' => $productId,
'integration_id' => $integrationId,
'name' => $name !== '' ? $name : null,
'short_description' => $shortDescription !== '' ? $shortDescription : null,
'description' => $description !== '' ? $description : null,
'created_at' => $now,
'updated_at' => $now,
]);
}
}

View File

@@ -1,479 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Products;
use PDO;
use Throwable;
final class ProductService
{
public function __construct(
private readonly PDO $pdo,
private readonly ProductRepository $products,
private readonly ProductValidator $validator
) {
}
/**
* @param array<string, mixed> $input
* @param array<string, mixed>|null $actor
* @return array{ok:bool, errors:array<int, string>, id?:int}
*/
public function create(array $input, ?array $actor): array
{
$errors = $this->validator->validate($input, false);
if ($errors !== []) {
return ['ok' => false, 'errors' => $errors];
}
$sku = trim((string) ($input['sku'] ?? ''));
if ($sku !== '' && $this->products->existsSku($sku)) {
return ['ok' => false, 'errors' => ['Podane SKU produktu jest juz zajete.']];
}
$normalized = $this->normalizeForSave($input);
$updatePayload = $this->toUpdatePayload($normalized['product']);
$actorId = isset($actor['id']) ? (int) $actor['id'] : null;
try {
$this->pdo->beginTransaction();
$productId = $this->products->create($normalized['product'], $normalized['translation']);
$this->products->logChange($productId, $actorId, 'product_created', null, $normalized['audit']);
$this->pdo->commit();
return ['ok' => true, 'errors' => [], 'id' => $productId];
} catch (Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
return ['ok' => false, 'errors' => ['Nie udalo sie zapisac produktu: ' . $exception->getMessage()]];
}
}
/**
* @param array<string, mixed> $input
* @param array<string, mixed>|null $actor
* @return array{ok:bool, errors:array<int, string>}
*/
public function update(int $id, array $input, ?array $actor): array
{
$existing = $this->products->findById($id, 'pl');
if ($existing === null) {
return ['ok' => false, 'errors' => ['Produkt nie istnieje.']];
}
// These fields are not edited in orderPRO UI, so keep current values when absent in request.
$input = $this->mergeMissingShopProSettingsFromExisting($input, $existing);
$errors = $this->validator->validate($input, true);
if ($errors !== []) {
return ['ok' => false, 'errors' => $errors];
}
$sku = trim((string) ($input['sku'] ?? ''));
if ($sku !== '' && $this->products->existsSku($sku, $id)) {
return ['ok' => false, 'errors' => ['Podane SKU produktu jest juz zajete.']];
}
$normalized = $this->normalizeForSave($input);
$updatePayload = $this->toUpdatePayload($normalized['product']);
$actorId = isset($actor['id']) ? (int) $actor['id'] : null;
try {
$this->pdo->beginTransaction();
$this->products->update($id, $updatePayload, $normalized['translation']);
$criticalBefore = $this->extractCriticalFields($existing);
$criticalAfter = $this->extractCriticalFields($normalized['audit']);
if ($criticalBefore !== $criticalAfter) {
$this->products->logChange($id, $actorId, 'product_updated', $criticalBefore, $criticalAfter);
}
$this->pdo->commit();
return ['ok' => true, 'errors' => []];
} catch (Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
return ['ok' => false, 'errors' => ['Nie udalo sie zaktualizowac produktu: ' . $exception->getMessage()]];
}
}
/**
* @param array<string, mixed>|null $actor
* @return array{ok:bool, errors:array<int, string>}
*/
public function delete(int $id, ?array $actor): array
{
$existing = $this->products->findById($id, 'pl');
if ($existing === null) {
return ['ok' => false, 'errors' => ['Produkt nie istnieje.']];
}
$imagePaths = $this->findProductImageStoragePaths($id);
try {
$this->pdo->beginTransaction();
$deleted = $this->products->deleteById($id);
if (!$deleted) {
throw new \RuntimeException('Nie udalo sie usunac produktu.');
}
$this->pdo->commit();
$this->deleteProductImageFiles($imagePaths);
return ['ok' => true, 'errors' => []];
} catch (Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
return ['ok' => false, 'errors' => ['Nie udalo sie usunac produktu: ' . $exception->getMessage()]];
}
}
/**
* @param array<string, mixed> $input
* @return array{product:array<string,mixed>, translation:array<string,mixed>, audit:array<string,mixed>}
*/
private function normalizeForSave(array $input): array
{
$vatRaw = trim((string) ($input['vat'] ?? ''));
$vat = $vatRaw === '' ? null : round((float) $vatRaw, 2);
$pricePair = $this->resolvePricePair(
trim((string) ($input['price_brutto'] ?? '')),
trim((string) ($input['price_netto'] ?? '')),
$vat,
(string) ($input['price_input_mode'] ?? 'brutto')
);
$promoPair = $this->resolvePricePair(
trim((string) ($input['price_brutto_promo'] ?? '')),
trim((string) ($input['price_netto_promo'] ?? '')),
$vat,
(string) ($input['price_input_mode'] ?? 'brutto'),
true
);
$now = date('Y-m-d H:i:s');
$product = [
'uuid' => $this->uuidV4(),
'type' => (string) ($input['type'] ?? 'simple'),
'sku' => $this->nullableString($input['sku'] ?? null),
'ean' => $this->nullableString($input['ean'] ?? null),
'status' => (int) ($input['status'] ?? 1),
'promoted' => (int) ($input['promoted'] ?? 0),
'new_to_date' => $this->nullableString($input['new_to_date'] ?? null),
'additional_message' => ((int) ($input['additional_message'] ?? 0)) === 1 ? 1 : 0,
'additional_message_required' => ((int) ($input['additional_message_required'] ?? 0)) === 1 ? 1 : 0,
'additional_message_text' => $this->nullableString($input['additional_message_text'] ?? null),
'vat' => $vat,
'weight' => $this->nullableFloat($input['weight'] ?? null, 3),
'price_brutto' => $pricePair['brutto'] ?? 0.00,
'price_brutto_promo' => $promoPair['brutto'],
'price_netto' => $pricePair['netto'],
'price_netto_promo' => $promoPair['netto'],
'quantity' => round((float) ($input['quantity'] ?? 0), 3),
'producer_id' => $this->nullableInt($input['producer_id'] ?? null),
'producer_name' => null,
'product_unit_id' => $this->nullableInt($input['product_unit_id'] ?? null),
'custom_fields_json' => null,
'created_at' => $now,
'updated_at' => $now,
];
$translation = [
'lang' => 'pl',
'name' => trim((string) ($input['name'] ?? '')),
'short_description' => $this->nullableString($input['short_description'] ?? null),
'description' => $this->nullableString($input['description'] ?? null),
'meta_title' => $this->nullableString($input['meta_title'] ?? null),
'meta_description' => $this->nullableString($input['meta_description'] ?? null),
'meta_keywords' => $this->nullableString($input['meta_keywords'] ?? null),
'seo_link' => $this->nullableString($input['seo_link'] ?? null),
'security_information' => null,
'created_at' => $now,
'updated_at' => $now,
];
$audit = [
'type' => $product['type'],
'sku' => $product['sku'],
'ean' => $product['ean'],
'status' => $product['status'],
'promoted' => $product['promoted'],
'vat' => $product['vat'],
'price_brutto' => $product['price_brutto'],
'price_netto' => $product['price_netto'],
'price_brutto_promo' => $product['price_brutto_promo'],
'price_netto_promo' => $product['price_netto_promo'],
'quantity' => $product['quantity'],
'name' => $translation['name'],
];
return [
'product' => $product,
'translation' => $translation,
'audit' => $audit,
];
}
/**
* @return array{brutto:float|null, netto:float|null}
*/
private function resolvePricePair(
string $bruttoRaw,
string $nettoRaw,
?float $vat,
string $mode,
bool $allowEmpty = false
): array {
if ($allowEmpty && $bruttoRaw === '' && $nettoRaw === '') {
return ['brutto' => null, 'netto' => null];
}
$multiplier = 1 + (($vat ?? 0.0) / 100);
if ($mode === 'netto') {
if ($nettoRaw === '' && $bruttoRaw !== '') {
$brutto = round((float) $bruttoRaw, 2);
$netto = $multiplier > 0 ? round($brutto / $multiplier, 2) : $brutto;
return ['brutto' => $brutto, 'netto' => $netto];
}
$netto = $nettoRaw === '' ? 0.0 : round((float) $nettoRaw, 2);
$brutto = round($netto * $multiplier, 2);
return ['brutto' => $brutto, 'netto' => $netto];
}
if ($bruttoRaw === '' && $nettoRaw !== '') {
$netto = round((float) $nettoRaw, 2);
$brutto = round($netto * $multiplier, 2);
return ['brutto' => $brutto, 'netto' => $netto];
}
$brutto = $bruttoRaw === '' ? 0.0 : round((float) $bruttoRaw, 2);
$netto = $multiplier > 0 ? round($brutto / $multiplier, 2) : $brutto;
return ['brutto' => $brutto, 'netto' => $netto];
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function extractCriticalFields(array $row): array
{
return [
'sku' => $row['sku'] ?? null,
'ean' => $row['ean'] ?? null,
'status' => $row['status'] ?? null,
'promoted' => $row['promoted'] ?? null,
'price_brutto' => $row['price_brutto'] ?? null,
'price_netto' => $row['price_netto'] ?? null,
'price_brutto_promo' => $row['price_brutto_promo'] ?? null,
'price_netto_promo' => $row['price_netto_promo'] ?? null,
'quantity' => $row['quantity'] ?? null,
'name' => $row['name'] ?? null,
];
}
private function nullableString(mixed $value): ?string
{
$text = trim((string) $value);
return $text === '' ? null : $text;
}
private function nullableInt(mixed $value): ?int
{
$text = trim((string) $value);
if ($text === '' || !is_numeric($text)) {
return null;
}
return (int) $text;
}
private function nullableFloat(mixed $value, int $precision = 2): ?float
{
$text = trim((string) $value);
if ($text === '' || !is_numeric($text)) {
return null;
}
return round((float) $text, $precision);
}
private function uuidV4(): string
{
$data = random_bytes(16);
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
/**
* @param array<string, mixed> $product
* @return array<string, mixed>
*/
private function toUpdatePayload(array $product): array
{
return [
'type' => $product['type'] ?? 'simple',
'sku' => $product['sku'] ?? null,
'ean' => $product['ean'] ?? null,
'status' => $product['status'] ?? 1,
'promoted' => $product['promoted'] ?? 0,
'new_to_date' => $product['new_to_date'] ?? null,
'additional_message' => ((int) ($product['additional_message'] ?? 0)) === 1 ? 1 : 0,
'additional_message_required' => ((int) ($product['additional_message_required'] ?? 0)) === 1 ? 1 : 0,
'additional_message_text' => $product['additional_message_text'] ?? null,
'vat' => $product['vat'] ?? null,
'weight' => $product['weight'] ?? null,
'price_brutto' => $product['price_brutto'] ?? 0,
'price_brutto_promo' => $product['price_brutto_promo'] ?? null,
'price_netto' => $product['price_netto'] ?? null,
'price_netto_promo' => $product['price_netto_promo'] ?? null,
'quantity' => $product['quantity'] ?? 0,
'producer_id' => $product['producer_id'] ?? null,
'producer_name' => $product['producer_name'] ?? null,
'product_unit_id' => $product['product_unit_id'] ?? null,
'custom_fields_json' => $product['custom_fields_json'] ?? null,
'updated_at' => date('Y-m-d H:i:s'),
];
}
/**
* @param array<string, mixed> $input
* @param array<string, mixed> $existing
* @return array<string, mixed>
*/
private function mergeMissingShopProSettingsFromExisting(array $input, array $existing): array
{
$keys = [
'new_to_date',
'additional_message',
'additional_message_required',
'additional_message_text',
];
foreach ($keys as $key) {
if (!array_key_exists($key, $input)) {
$input[$key] = $existing[$key] ?? null;
}
}
return $input;
}
/**
* @return array<int, string>
*/
private function findProductImageStoragePaths(int $productId): array
{
$stmt = $this->pdo->prepare('SELECT storage_path FROM product_images WHERE product_id = :product_id');
$stmt->execute(['product_id' => $productId]);
$rows = $stmt->fetchAll();
if (!is_array($rows)) {
return [];
}
$paths = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$path = trim((string) ($row['storage_path'] ?? ''));
if ($path !== '') {
$paths[] = $path;
}
}
return array_values(array_unique($paths));
}
/**
* @param array<int, string> $storagePaths
*/
private function deleteProductImageFiles(array $storagePaths): void
{
foreach ($storagePaths as $storagePath) {
if ($this->storagePathHasOtherReferences($storagePath)) {
continue;
}
$resolvedFilePath = $this->resolveLocalImageFilePath($storagePath);
if ($resolvedFilePath === null || !is_file($resolvedFilePath)) {
continue;
}
@unlink($resolvedFilePath);
}
}
private function storagePathHasOtherReferences(string $storagePath): bool
{
$stmt = $this->pdo->prepare(
'SELECT 1 FROM product_images WHERE storage_path = :storage_path LIMIT 1'
);
$stmt->execute(['storage_path' => $storagePath]);
return $stmt->fetchColumn() !== false;
}
private function resolveLocalImageFilePath(string $storagePath): ?string
{
$path = trim($storagePath);
if ($path === '') {
return null;
}
if (preg_match('#^https?://#i', $path) === 1 || str_starts_with($path, '//')) {
return null;
}
$projectRoot = dirname(__DIR__, 3);
$projectRootReal = realpath($projectRoot);
if ($projectRootReal === false) {
return null;
}
$trimmed = ltrim(str_replace(['\\', '/'], DIRECTORY_SEPARATOR, $path), DIRECTORY_SEPARATOR);
$candidates = [];
if ($this->isAbsolutePath($path)) {
$candidates[] = str_replace(['\\', '/'], DIRECTORY_SEPARATOR, $path);
}
$candidates[] = $projectRoot . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . $trimmed;
$candidates[] = $projectRoot . DIRECTORY_SEPARATOR . $trimmed;
foreach ($candidates as $candidate) {
$real = realpath($candidate);
if ($real === false || !is_file($real)) {
continue;
}
if ($real === $projectRootReal || str_starts_with($real, $projectRootReal . DIRECTORY_SEPARATOR)) {
return $real;
}
}
return null;
}
private function isAbsolutePath(string $path): bool
{
if ($path === '') {
return false;
}
return preg_match('/^[A-Za-z]:[\\\\\\/]/', $path) === 1 || str_starts_with($path, DIRECTORY_SEPARATOR);
}
}

View File

@@ -1,103 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Products;
use App\Modules\Settings\AppSettingsRepository;
use RuntimeException;
final class ProductSkuGenerator
{
public function __construct(
private readonly AppSettingsRepository $appSettings,
private readonly ProductRepository $products
) {
}
public function format(): string
{
return $this->normalizeFormat($this->appSettings->get('products_sku_format', 'PP000000'));
}
public function nextSku(): string
{
$format = $this->format();
[$prefix, $width, $suffix] = $this->parseFormat($format);
$maxNumber = 0;
foreach ($this->products->findAllSkus() as $sku) {
$number = $this->extractNumber($sku, $prefix, $width, $suffix);
if ($number !== null && $number > $maxNumber) {
$maxNumber = $number;
}
}
$candidateNumber = max(1, $maxNumber + 1);
while (true) {
$candidate = $prefix . str_pad((string) $candidateNumber, $width, '0', STR_PAD_LEFT) . $suffix;
if (!$this->products->existsSku($candidate)) {
return $candidate;
}
$candidateNumber++;
if ($candidateNumber > 999999999) {
throw new RuntimeException('Nie udalo sie wygenerowac kolejnego SKU.');
}
}
}
private function normalizeFormat(?string $raw): string
{
$value = trim((string) $raw);
return $value === '' ? 'PP000000' : $value;
}
/**
* @return array{0:string,1:int,2:string}
*/
private function parseFormat(string $format): array
{
if (mb_strlen($format) > 128) {
throw new RuntimeException('Format SKU jest za dlugi (maksymalnie 128 znakow).');
}
if (preg_match('/0+/', $format, $matches, PREG_OFFSET_CAPTURE) !== 1) {
throw new RuntimeException('Format SKU musi zawierac czesc liczbowa (zera), np. PP000000.');
}
$token = (string) $matches[0][0];
$offset = (int) $matches[0][1];
$width = strlen($token);
$prefix = substr($format, 0, $offset);
$suffix = substr($format, $offset + $width);
return [$prefix, $width, $suffix === false ? '' : $suffix];
}
private function extractNumber(string $sku, string $prefix, int $width, string $suffix): ?int
{
$prefixLen = strlen($prefix);
$suffixLen = strlen($suffix);
$skuLen = strlen($sku);
if ($skuLen !== ($prefixLen + $width + $suffixLen)) {
return null;
}
if ($prefix !== '' && !str_starts_with($sku, $prefix)) {
return null;
}
if ($suffix !== '' && !str_ends_with($sku, $suffix)) {
return null;
}
$numberPart = substr($sku, $prefixLen, $width);
if ($numberPart === false || preg_match('/^\d+$/', $numberPart) !== 1) {
return null;
}
return (int) $numberPart;
}
}

View File

@@ -1,102 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Products;
final class ProductValidator
{
/**
* @param array<string, mixed> $input
* @param bool $isUpdate
* @return array<int, string>
*/
public function validate(array $input, bool $isUpdate = false): array
{
$errors = [];
$name = trim((string) ($input['name'] ?? ''));
if (mb_strlen($name) < 2) {
$errors[] = 'Nazwa produktu musi miec co najmniej 2 znaki.';
}
$type = (string) ($input['type'] ?? 'simple');
if (!in_array($type, ['simple', 'variant_parent'], true)) {
$errors[] = 'Niepoprawny typ produktu.';
}
$sku = trim((string) ($input['sku'] ?? ''));
if ($sku !== '' && mb_strlen($sku) > 128) {
$errors[] = 'SKU produktu moze miec maksymalnie 128 znakow.';
}
$ean = trim((string) ($input['ean'] ?? ''));
if ($ean !== '' && !preg_match('/^[0-9]{8,14}$/', $ean)) {
$errors[] = 'EAN musi zawierac od 8 do 14 cyfr.';
}
$status = (string) ($input['status'] ?? '1');
if (!in_array($status, ['0', '1'], true)) {
$errors[] = 'Status produktu jest niepoprawny.';
}
$promoted = (string) ($input['promoted'] ?? '0');
if (!in_array($promoted, ['0', '1'], true)) {
$errors[] = 'Flaga promocji jest niepoprawna.';
}
$priceInputMode = (string) ($input['price_input_mode'] ?? 'brutto');
if (!in_array($priceInputMode, ['brutto', 'netto'], true)) {
$errors[] = 'Tryb wprowadzania ceny jest niepoprawny.';
}
$vat = trim((string) ($input['vat'] ?? ''));
if ($vat !== '' && !is_numeric($vat)) {
$errors[] = 'Stawka VAT musi byc liczba.';
}
if ($vat !== '' && is_numeric($vat)) {
$vatValue = (float) $vat;
if ($vatValue < 0 || $vatValue > 100) {
$errors[] = 'Stawka VAT musi byc w zakresie 0-100.';
}
}
$quantity = trim((string) ($input['quantity'] ?? '0'));
if ($quantity === '' || !is_numeric($quantity) || (float) $quantity < 0) {
$errors[] = 'Stan magazynowy musi byc liczba >= 0.';
}
$weight = trim((string) ($input['weight'] ?? ''));
if ($weight !== '' && (!is_numeric($weight) || (float) $weight < 0)) {
$errors[] = 'Waga musi byc liczba >= 0.';
}
$priceBrutto = trim((string) ($input['price_brutto'] ?? ''));
$priceNetto = trim((string) ($input['price_netto'] ?? ''));
if ($priceInputMode === 'brutto') {
if ($priceBrutto === '' || !is_numeric($priceBrutto) || (float) $priceBrutto < 0) {
$errors[] = 'Cena brutto jest wymagana i musi byc liczba >= 0.';
}
}
if ($priceInputMode === 'netto') {
if ($priceNetto === '' || !is_numeric($priceNetto) || (float) $priceNetto < 0) {
$errors[] = 'Cena netto jest wymagana i musi byc liczba >= 0.';
}
}
$priceBruttoPromo = trim((string) ($input['price_brutto_promo'] ?? ''));
$priceNettoPromo = trim((string) ($input['price_netto_promo'] ?? ''));
if ($priceBruttoPromo !== '' && (!is_numeric($priceBruttoPromo) || (float) $priceBruttoPromo < 0)) {
$errors[] = 'Cena promocyjna brutto musi byc liczba >= 0.';
}
if ($priceNettoPromo !== '' && (!is_numeric($priceNettoPromo) || (float) $priceNettoPromo < 0)) {
$errors[] = 'Cena promocyjna netto musi byc liczba >= 0.';
}
return $errors;
}
}

View File

@@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
final class AppSettingsRepository
{
public function __construct(private readonly PDO $pdo)
{
}
public function get(string $key, ?string $default = null): ?string
{
$statement = $this->pdo->prepare(
'SELECT setting_value
FROM app_settings
WHERE setting_key = :setting_key
LIMIT 1'
);
$statement->execute(['setting_key' => trim($key)]);
$value = $statement->fetchColumn();
if ($value === false || $value === null) {
return $default;
}
$text = trim((string) $value);
return $text === '' ? $default : $text;
}
public function getBool(string $key, bool $default = false): bool
{
$value = $this->get($key);
if ($value === null) {
return $default;
}
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true);
}
public function getInt(string $key, int $default = 0): int
{
$value = $this->get($key);
if ($value === null || !is_numeric($value)) {
return $default;
}
return (int) $value;
}
public function set(string $key, string $value): void
{
$statement = $this->pdo->prepare(
'INSERT INTO app_settings (setting_key, setting_value, created_at, updated_at)
VALUES (:setting_key, :setting_value, :created_at, :updated_at)
ON DUPLICATE KEY UPDATE
setting_value = VALUES(setting_value),
updated_at = VALUES(updated_at)'
);
$now = date('Y-m-d H:i:s');
$statement->execute([
'setting_key' => trim($key),
'setting_value' => trim($value),
'created_at' => $now,
'updated_at' => $now,
]);
}
}

View File

@@ -1,679 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
use RuntimeException;
final class IntegrationRepository
{
private ?bool $ordersFetchColumnsAvailable = null;
private ?bool $orderStatusSyncDirectionColumnAvailable = null;
private const ORDER_STATUS_SYNC_DIRECTION_DEFAULT = 'shoppro_to_orderpro';
public function __construct(
private readonly PDO $pdo,
private readonly string $secret
) {
}
/**
* @return array<int, array<string, mixed>>
*/
public function listByType(string $type): array
{
$statement = $this->pdo->prepare(
'SELECT id, type, name, base_url, timeout_seconds, is_active'
. $this->ordersFetchSelectFragment()
. $this->orderStatusSyncDirectionSelectFragment() . ',
last_test_status, last_test_http_code, last_test_message, last_test_at,
created_at, updated_at,
CASE WHEN api_key_encrypted IS NULL OR api_key_encrypted = "" THEN 0 ELSE 1 END AS has_api_key
FROM integrations
WHERE type = :type
ORDER BY id DESC'
);
$statement->execute(['type' => $type]);
$rows = $statement->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map([$this, 'mapRow'], $rows);
}
/**
* @return array<string, mixed>|null
*/
public function findById(int $id): ?array
{
$statement = $this->pdo->prepare(
'SELECT id, type, name, base_url, timeout_seconds, is_active'
. $this->ordersFetchSelectFragment()
. $this->orderStatusSyncDirectionSelectFragment() . ',
last_test_status, last_test_http_code, last_test_message, last_test_at,
created_at, updated_at,
CASE WHEN api_key_encrypted IS NULL OR api_key_encrypted = "" THEN 0 ELSE 1 END AS has_api_key
FROM integrations
WHERE id = :id
LIMIT 1'
);
$statement->execute(['id' => $id]);
$row = $statement->fetch();
if (!is_array($row)) {
return null;
}
return $this->mapRow($row);
}
/**
* @return array<string, mixed>|null
*/
public function findApiCredentials(int $id): ?array
{
$statement = $this->pdo->prepare(
'SELECT id, name, base_url, timeout_seconds, api_key_encrypted'
. $this->ordersFetchSelectFragment()
. $this->orderStatusSyncDirectionSelectFragment() . '
FROM integrations
WHERE id = :id
LIMIT 1'
);
$statement->execute(['id' => $id]);
$row = $statement->fetch();
if (!is_array($row)) {
return null;
}
return [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'base_url' => (string) ($row['base_url'] ?? ''),
'timeout_seconds' => (int) ($row['timeout_seconds'] ?? 10),
'api_key' => $this->decryptApiKey((string) ($row['api_key_encrypted'] ?? '')),
'orders_fetch_enabled' => (int) ($row['orders_fetch_enabled'] ?? 0) === 1,
'orders_fetch_start_date' => $row['orders_fetch_start_date'] === null ? null : (string) $row['orders_fetch_start_date'],
'order_status_sync_direction' => $this->normalizeOrderStatusSyncDirection((string) ($row['order_status_sync_direction'] ?? self::ORDER_STATUS_SYNC_DIRECTION_DEFAULT)),
];
}
/**
* @return array<string, mixed>|null
*/
public function findActiveApiCredentialsByType(string $type): ?array
{
$statement = $this->pdo->prepare(
'SELECT id, name, base_url, timeout_seconds, api_key_encrypted'
. $this->ordersFetchSelectFragment()
. $this->orderStatusSyncDirectionSelectFragment() . '
FROM integrations
WHERE type = :type AND is_active = 1
ORDER BY id DESC
LIMIT 1'
);
$statement->execute(['type' => $type]);
$row = $statement->fetch();
if (!is_array($row)) {
return null;
}
return [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'base_url' => (string) ($row['base_url'] ?? ''),
'timeout_seconds' => (int) ($row['timeout_seconds'] ?? 10),
'api_key' => $this->decryptApiKey((string) ($row['api_key_encrypted'] ?? '')),
'orders_fetch_enabled' => (int) ($row['orders_fetch_enabled'] ?? 0) === 1,
'orders_fetch_start_date' => $row['orders_fetch_start_date'] === null ? null : (string) $row['orders_fetch_start_date'],
'order_status_sync_direction' => $this->normalizeOrderStatusSyncDirection((string) ($row['order_status_sync_direction'] ?? self::ORDER_STATUS_SYNC_DIRECTION_DEFAULT)),
];
}
public function create(
string $type,
string $name,
string $baseUrl,
int $timeoutSeconds,
bool $isActive,
string $apiKey,
bool $ordersFetchEnabled = false,
?string $ordersFetchStartDate = null,
string $orderStatusSyncDirection = self::ORDER_STATUS_SYNC_DIRECTION_DEFAULT
): int {
$normalizedSyncDirection = $this->normalizeOrderStatusSyncDirection($orderStatusSyncDirection);
if ($this->hasOrdersFetchColumns() && $this->hasOrderStatusSyncDirectionColumn()) {
$statement = $this->pdo->prepare(
'INSERT INTO integrations (
type, name, base_url, api_key_encrypted, timeout_seconds, is_active,
orders_fetch_enabled, orders_fetch_start_date, order_status_sync_direction,
created_at, updated_at
) VALUES (
:type, :name, :base_url, :api_key_encrypted, :timeout_seconds, :is_active,
:orders_fetch_enabled, :orders_fetch_start_date, :order_status_sync_direction,
:created_at, :updated_at
)'
);
$statement->execute([
'type' => $type,
'name' => $name,
'base_url' => $baseUrl,
'api_key_encrypted' => $this->encryptApiKey($apiKey),
'timeout_seconds' => $timeoutSeconds,
'is_active' => $isActive ? 1 : 0,
'orders_fetch_enabled' => $ordersFetchEnabled ? 1 : 0,
'orders_fetch_start_date' => $ordersFetchStartDate,
'order_status_sync_direction' => $normalizedSyncDirection,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
} elseif ($this->hasOrdersFetchColumns()) {
$statement = $this->pdo->prepare(
'INSERT INTO integrations (
type, name, base_url, api_key_encrypted, timeout_seconds, is_active,
orders_fetch_enabled, orders_fetch_start_date,
created_at, updated_at
) VALUES (
:type, :name, :base_url, :api_key_encrypted, :timeout_seconds, :is_active,
:orders_fetch_enabled, :orders_fetch_start_date,
:created_at, :updated_at
)'
);
$statement->execute([
'type' => $type,
'name' => $name,
'base_url' => $baseUrl,
'api_key_encrypted' => $this->encryptApiKey($apiKey),
'timeout_seconds' => $timeoutSeconds,
'is_active' => $isActive ? 1 : 0,
'orders_fetch_enabled' => $ordersFetchEnabled ? 1 : 0,
'orders_fetch_start_date' => $ordersFetchStartDate,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
} else {
$statement = $this->pdo->prepare(
'INSERT INTO integrations (
type, name, base_url, api_key_encrypted, timeout_seconds, is_active, created_at, updated_at
) VALUES (
:type, :name, :base_url, :api_key_encrypted, :timeout_seconds, :is_active, :created_at, :updated_at
)'
);
$statement->execute([
'type' => $type,
'name' => $name,
'base_url' => $baseUrl,
'api_key_encrypted' => $this->encryptApiKey($apiKey),
'timeout_seconds' => $timeoutSeconds,
'is_active' => $isActive ? 1 : 0,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
return (int) $this->pdo->lastInsertId();
}
public function update(
int $id,
string $name,
string $baseUrl,
int $timeoutSeconds,
bool $isActive,
?string $apiKey,
bool $ordersFetchEnabled = false,
?string $ordersFetchStartDate = null,
string $orderStatusSyncDirection = self::ORDER_STATUS_SYNC_DIRECTION_DEFAULT
): void {
$normalizedSyncDirection = $this->normalizeOrderStatusSyncDirection($orderStatusSyncDirection);
$params = [
'id' => $id,
'name' => $name,
'base_url' => $baseUrl,
'timeout_seconds' => $timeoutSeconds,
'is_active' => $isActive ? 1 : 0,
'orders_fetch_enabled' => $ordersFetchEnabled ? 1 : 0,
'orders_fetch_start_date' => $ordersFetchStartDate,
'order_status_sync_direction' => $normalizedSyncDirection,
'updated_at' => date('Y-m-d H:i:s'),
];
$sql = 'UPDATE integrations SET
name = :name,
base_url = :base_url,
timeout_seconds = :timeout_seconds,
is_active = :is_active,
updated_at = :updated_at';
if ($this->hasOrdersFetchColumns() && $this->hasOrderStatusSyncDirectionColumn()) {
$sql = 'UPDATE integrations SET
name = :name,
base_url = :base_url,
timeout_seconds = :timeout_seconds,
is_active = :is_active,
orders_fetch_enabled = :orders_fetch_enabled,
orders_fetch_start_date = :orders_fetch_start_date,
order_status_sync_direction = :order_status_sync_direction,
updated_at = :updated_at';
} elseif ($this->hasOrdersFetchColumns()) {
$sql = 'UPDATE integrations SET
name = :name,
base_url = :base_url,
timeout_seconds = :timeout_seconds,
is_active = :is_active,
orders_fetch_enabled = :orders_fetch_enabled,
orders_fetch_start_date = :orders_fetch_start_date,
updated_at = :updated_at';
unset($params['order_status_sync_direction']);
} else {
unset($params['orders_fetch_enabled'], $params['orders_fetch_start_date'], $params['order_status_sync_direction']);
}
if ($apiKey !== null && trim($apiKey) !== '') {
$sql .= ', api_key_encrypted = :api_key_encrypted';
$params['api_key_encrypted'] = $this->encryptApiKey($apiKey);
}
$sql .= ' WHERE id = :id';
$statement = $this->pdo->prepare($sql);
$statement->execute($params);
}
public function setTestResult(
int $id,
string $status,
?int $httpCode,
string $message,
string $testedAt
): void {
$statement = $this->pdo->prepare(
'UPDATE integrations SET
last_test_status = :status,
last_test_http_code = :http_code,
last_test_message = :message,
last_test_at = :tested_at,
updated_at = :updated_at
WHERE id = :id'
);
$statement->execute([
'id' => $id,
'status' => $status,
'http_code' => $httpCode,
'message' => mb_substr($message, 0, 255),
'tested_at' => $testedAt,
'updated_at' => date('Y-m-d H:i:s'),
]);
}
public function logTest(
int $integrationId,
string $status,
?int $httpCode,
string $message,
string $endpointUrl,
string $testedAt
): void {
$statement = $this->pdo->prepare(
'INSERT INTO integration_test_logs (
integration_id, status, http_code, message, endpoint_url, tested_at
) VALUES (
:integration_id, :status, :http_code, :message, :endpoint_url, :tested_at
)'
);
$statement->execute([
'integration_id' => $integrationId,
'status' => $status,
'http_code' => $httpCode,
'message' => mb_substr($message, 0, 255),
'endpoint_url' => mb_substr($endpointUrl, 0, 255),
'tested_at' => $testedAt,
]);
}
/**
* @return array<int, array<string, mixed>>
*/
public function recentTests(int $integrationId, int $limit = 5): array
{
$statement = $this->pdo->prepare(
'SELECT id, integration_id, status, http_code, message, endpoint_url, tested_at
FROM integration_test_logs
WHERE integration_id = :integration_id
ORDER BY tested_at DESC, id DESC
LIMIT :limit'
);
$statement->bindValue(':integration_id', $integrationId, PDO::PARAM_INT);
$statement->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
$statement->execute();
$rows = $statement->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map(
static fn (array $row): array => [
'id' => (int) ($row['id'] ?? 0),
'integration_id' => (int) ($row['integration_id'] ?? 0),
'status' => (string) ($row['status'] ?? ''),
'http_code' => $row['http_code'] === null ? null : (int) $row['http_code'],
'message' => (string) ($row['message'] ?? ''),
'endpoint_url' => (string) ($row['endpoint_url'] ?? ''),
'tested_at' => (string) ($row['tested_at'] ?? ''),
],
$rows
);
}
public function nameExists(string $type, string $name, ?int $excludeId = null): bool
{
$sql = 'SELECT 1 FROM integrations WHERE type = :type AND name = :name';
$params = [
'type' => $type,
'name' => $name,
];
if ($excludeId !== null) {
$sql .= ' AND id <> :exclude_id';
$params['exclude_id'] = $excludeId;
}
$sql .= ' LIMIT 1';
$statement = $this->pdo->prepare($sql);
$statement->execute($params);
return $statement->fetchColumn() !== false;
}
public function ensureSalesChannelsSeeded(): void
{
$rows = [
['code' => 'shoppro', 'name' => 'shopPRO', 'type' => 'shop_instance'],
['code' => 'allegro', 'name' => 'Allegro', 'type' => 'marketplace'],
['code' => 'erli', 'name' => 'Erli', 'type' => 'marketplace'],
];
$statement = $this->pdo->prepare(
'INSERT INTO sales_channels (code, name, type, status, created_at, updated_at)
VALUES (:code, :name, :type, 1, :created_at, :updated_at)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
type = VALUES(type),
updated_at = VALUES(updated_at)'
);
$now = date('Y-m-d H:i:s');
foreach ($rows as $row) {
$statement->execute([
'code' => $row['code'],
'name' => $row['name'],
'type' => $row['type'],
'created_at' => $now,
'updated_at' => $now,
]);
}
}
public function findMappedProductId(string $channelCode, string $externalProductId, ?int $integrationId = null): ?int
{
$sql = 'SELECT pcm.product_id
FROM product_channel_map pcm
INNER JOIN sales_channels sc ON sc.id = pcm.channel_id
WHERE sc.code = :channel_code
AND pcm.external_product_id = :external_product_id';
$params = [
'channel_code' => $channelCode,
'external_product_id' => $externalProductId,
];
if ($integrationId !== null && $integrationId > 0) {
$sql .= ' AND pcm.integration_id = :integration_id';
$params['integration_id'] = $integrationId;
}
$sql .= ' LIMIT 1';
$statement = $this->pdo->prepare($sql);
$statement->execute($params);
$value = $statement->fetchColumn();
if ($value === false) {
return null;
}
return (int) $value;
}
public function upsertProductChannelMap(
int $productId,
string $channelCode,
string $syncState,
string $externalProductId = '',
string $externalVariantId = '',
?int $integrationId = null
): void {
$channelId = $this->findChannelIdByCode($channelCode);
if ($channelId === null) {
throw new RuntimeException('Brak kanalu sprzedazy: ' . $channelCode);
}
$externalProductId = trim($externalProductId);
$externalVariantId = trim($externalVariantId);
$normalizedIntegrationId = $integrationId !== null && $integrationId > 0
? $integrationId
: null;
$linkType = 'manual';
$linkStatus = $externalProductId !== '' ? 'active' : 'unverified';
$linkedAt = $externalProductId !== '' ? date('Y-m-d H:i:s') : null;
$statement = $this->pdo->prepare(
'INSERT INTO product_channel_map (
product_id, channel_id, integration_id, external_product_id, external_variant_id,
sync_state, link_type, link_status, linked_at, last_sync_at, created_at, updated_at
) VALUES (
:product_id, :channel_id, :integration_id, :external_product_id, :external_variant_id,
:sync_state, :link_type, :link_status, :linked_at, :last_sync_at, :created_at, :updated_at
) ON DUPLICATE KEY UPDATE
integration_id = VALUES(integration_id),
sync_state = VALUES(sync_state),
last_sync_at = VALUES(last_sync_at),
external_product_id = VALUES(external_product_id),
external_variant_id = VALUES(external_variant_id),
link_type = VALUES(link_type),
link_status = VALUES(link_status),
linked_at = VALUES(linked_at),
updated_at = VALUES(updated_at)'
);
$now = date('Y-m-d H:i:s');
$statement->execute([
'product_id' => $productId,
'channel_id' => $channelId,
'integration_id' => $normalizedIntegrationId,
'external_product_id' => $externalProductId,
'external_variant_id' => $externalVariantId !== '' ? $externalVariantId : null,
'sync_state' => $syncState,
'link_type' => $linkType,
'link_status' => $linkStatus,
'linked_at' => $linkedAt,
'last_sync_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function mapRow(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'type' => (string) ($row['type'] ?? ''),
'name' => (string) ($row['name'] ?? ''),
'base_url' => (string) ($row['base_url'] ?? ''),
'timeout_seconds' => (int) ($row['timeout_seconds'] ?? 10),
'is_active' => (int) ($row['is_active'] ?? 0) === 1,
'orders_fetch_enabled' => (int) ($row['orders_fetch_enabled'] ?? 0) === 1,
'orders_fetch_start_date' => $row['orders_fetch_start_date'] === null ? null : (string) $row['orders_fetch_start_date'],
'order_status_sync_direction' => $this->normalizeOrderStatusSyncDirection((string) ($row['order_status_sync_direction'] ?? self::ORDER_STATUS_SYNC_DIRECTION_DEFAULT)),
'last_test_status' => (string) ($row['last_test_status'] ?? ''),
'last_test_http_code' => $row['last_test_http_code'] === null ? null : (int) $row['last_test_http_code'],
'last_test_message' => (string) ($row['last_test_message'] ?? ''),
'last_test_at' => (string) ($row['last_test_at'] ?? ''),
'has_api_key' => (int) ($row['has_api_key'] ?? 0) === 1,
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}
private function encryptApiKey(string $apiKey): string
{
$plain = trim($apiKey);
if ($plain === '') {
return '';
}
$secret = trim($this->secret);
if ($secret === '') {
throw new RuntimeException('Brak INTEGRATIONS_SECRET w konfiguracji .env.');
}
$iv = random_bytes(16);
$cipher = openssl_encrypt(
$plain,
'AES-256-CBC',
hash('sha256', $secret, true),
OPENSSL_RAW_DATA,
$iv
);
if ($cipher === false) {
throw new RuntimeException('Nie mozna zaszyfrowac klucza API.');
}
return base64_encode($iv) . ':' . base64_encode($cipher);
}
private function decryptApiKey(string $payload): string
{
$serialized = trim($payload);
if ($serialized === '') {
return '';
}
$secret = trim($this->secret);
if ($secret === '') {
throw new RuntimeException('Brak INTEGRATIONS_SECRET w konfiguracji .env.');
}
$parts = explode(':', $serialized, 2);
if (count($parts) !== 2) {
throw new RuntimeException('Niepoprawny format zapisanego klucza API.');
}
$iv = base64_decode($parts[0], true);
$cipher = base64_decode($parts[1], true);
if ($iv === false || $cipher === false || strlen($iv) !== 16) {
throw new RuntimeException('Nie mozna odczytac zapisanego klucza API.');
}
$plain = openssl_decrypt(
$cipher,
'AES-256-CBC',
hash('sha256', $secret, true),
OPENSSL_RAW_DATA,
$iv
);
if ($plain === false) {
throw new RuntimeException('Nie mozna odszyfrowac zapisanego klucza API.');
}
return $plain;
}
private function findChannelIdByCode(string $code): ?int
{
$statement = $this->pdo->prepare('SELECT id FROM sales_channels WHERE code = :code LIMIT 1');
$statement->execute(['code' => $code]);
$value = $statement->fetchColumn();
if ($value === false) {
return null;
}
return (int) $value;
}
private function ordersFetchSelectFragment(): string
{
if ($this->hasOrdersFetchColumns()) {
return ', orders_fetch_enabled, orders_fetch_start_date';
}
return ', 0 AS orders_fetch_enabled, NULL AS orders_fetch_start_date';
}
private function orderStatusSyncDirectionSelectFragment(): string
{
if ($this->hasOrderStatusSyncDirectionColumn()) {
return ', order_status_sync_direction';
}
return ", '" . self::ORDER_STATUS_SYNC_DIRECTION_DEFAULT . "' AS order_status_sync_direction";
}
private function hasOrdersFetchColumns(): bool
{
if ($this->ordersFetchColumnsAvailable !== null) {
return $this->ordersFetchColumnsAvailable;
}
try {
$enabledStmt = $this->pdo->query("SHOW COLUMNS FROM integrations LIKE 'orders_fetch_enabled'");
$startDateStmt = $this->pdo->query("SHOW COLUMNS FROM integrations LIKE 'orders_fetch_start_date'");
$this->ordersFetchColumnsAvailable =
$enabledStmt !== false
&& $enabledStmt->fetch() !== false
&& $startDateStmt !== false
&& $startDateStmt->fetch() !== false;
} catch (\Throwable) {
$this->ordersFetchColumnsAvailable = false;
}
return $this->ordersFetchColumnsAvailable;
}
private function hasOrderStatusSyncDirectionColumn(): bool
{
if ($this->orderStatusSyncDirectionColumnAvailable !== null) {
return $this->orderStatusSyncDirectionColumnAvailable;
}
try {
$stmt = $this->pdo->query("SHOW COLUMNS FROM integrations LIKE 'order_status_sync_direction'");
$this->orderStatusSyncDirectionColumnAvailable =
$stmt !== false
&& $stmt->fetch() !== false;
} catch (\Throwable) {
$this->orderStatusSyncDirectionColumnAvailable = false;
}
return $this->orderStatusSyncDirectionColumnAvailable;
}
private function normalizeOrderStatusSyncDirection(string $value): string
{
$normalized = trim(mb_strtolower($value));
if ($normalized === 'orderpro_to_shoppro') {
return 'orderpro_to_shoppro';
}
return self::ORDER_STATUS_SYNC_DIRECTION_DEFAULT;
}
}

View File

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

View File

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

View File

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

View File

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