update
This commit is contained in:
@@ -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
|
||||||
|
|||||||
20
.claude/memory/project_psd_personalize.md
Normal file
20
.claude/memory/project_psd_personalize.md
Normal 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`
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
|
|
||||||
@@ -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' ? ' ↑' : ' ↓') : '' ?>
|
|
||||||
</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' ? ' ↑' : ' ↓') : '' ?>
|
|
||||||
</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' ? ' ↑' : ' ↓') : '' ?>
|
|
||||||
</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' ? ' ↑' : ' ↓') : '' ?>
|
|
||||||
</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' ? ' ↑' : ' ↓') : '' ?>
|
|
||||||
</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' ? ' ↑' : ' ↓') : '' ?>
|
|
||||||
</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' ? ' ↑' : ' ↓') : '' ?>
|
|
||||||
</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' ? ' ↑' : ' ↓') : '' ?>
|
|
||||||
</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' ? ' ↑' : ' ↓') : '' ?>
|
|
||||||
</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])) ?>">«</a>
|
|
||||||
<a class="pagination__item<?= $page <= 1 ? ' is-disabled' : '' ?>" href="<?= $e($buildUrl(['page' => max(1, $page - 1)])) ?>">‹</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)])) ?>">›</a>
|
|
||||||
<a class="pagination__item<?= $page >= $totalPages ? ' is-disabled' : '' ?>" href="<?= $e($buildUrl(['page' => $totalPages])) ?>">»</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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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; ?>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'] ?? ''),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) ?? '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'] ?? ''),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user