Add initial HTML template for MojeGS1 application with Cookiebot and Google Analytics integration

This commit is contained in:
2026-02-24 23:32:19 +01:00
parent 18d0019c28
commit 12f0c262c8
67 changed files with 50193 additions and 230 deletions

View File

@@ -13,6 +13,16 @@ use App\Core\Support\Logger;
use App\Core\Support\Session;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use App\Modules\Cron\CronJobProcessor;
use App\Modules\Cron\CronJobRepository;
use App\Modules\Cron\CronJobType;
use App\Modules\Cron\ProductLinksHealthCheckHandler;
use App\Modules\ProductLinks\ChannelOffersRepository;
use App\Modules\ProductLinks\OfferImportService;
use App\Modules\ProductLinks\ProductLinksRepository;
use App\Modules\Settings\AppSettingsRepository;
use App\Modules\Settings\IntegrationRepository;
use App\Modules\Settings\ShopProClient;
use App\Modules\Users\UserRepository;
use PDO;
use Throwable;
@@ -64,6 +74,7 @@ final class Application
public function run(): void
{
$request = Request::capture();
$this->maybeRunCronOnWeb($request);
$response = $this->router->dispatch($request);
$response->send();
}
@@ -196,4 +207,98 @@ final class Application
return false;
});
}
private function maybeRunCronOnWeb(Request $request): void
{
if ($request->method() !== 'GET') {
return;
}
if ($request->path() === '/health') {
return;
}
$enabled = (bool) $this->config('app.cron.run_on_web_default', false);
$limit = max(1, min(100, (int) $this->config('app.cron.web_limit_default', 5)));
try {
$appSettings = new AppSettingsRepository($this->db);
$enabled = $appSettings->getBool('cron_run_on_web', $enabled);
$limit = max(1, min(100, $appSettings->getInt('cron_web_limit', $limit)));
} catch (Throwable $exception) {
$this->logger->error('Cron web settings load failed', [
'message' => $exception->getMessage(),
]);
}
if (!$enabled) {
return;
}
if ($this->isWebCronThrottled(20)) {
return;
}
if (!$this->acquireWebCronLock()) {
return;
}
try {
$cronJobs = new CronJobRepository($this->db);
$processor = new CronJobProcessor($cronJobs);
$integrationRepository = new IntegrationRepository(
$this->db,
(string) $this->config('app.integrations.secret', '')
);
$offersRepository = new ChannelOffersRepository($this->db);
$linksRepository = new ProductLinksRepository($this->db);
$shopProClient = new ShopProClient();
$offerImportService = new OfferImportService($shopProClient, $offersRepository, $this->db);
$linksHealthCheckHandler = new ProductLinksHealthCheckHandler(
$integrationRepository,
$offerImportService,
$linksRepository,
$offersRepository
);
$processor->registerHandler(CronJobType::PRODUCT_LINKS_HEALTH_CHECK, $linksHealthCheckHandler);
$result = $processor->run($limit);
$this->logger->info('Cron web run completed', $result);
} catch (Throwable $exception) {
$this->logger->error('Cron web run failed', [
'message' => $exception->getMessage(),
]);
} finally {
$this->releaseWebCronLock();
}
}
private function isWebCronThrottled(int $minIntervalSeconds): bool
{
$safeInterval = max(1, $minIntervalSeconds);
$now = time();
$lastRunAt = isset($_SESSION['cron_web_last_run_at']) ? (int) $_SESSION['cron_web_last_run_at'] : 0;
if ($lastRunAt > 0 && ($now - $lastRunAt) < $safeInterval) {
return true;
}
$_SESSION['cron_web_last_run_at'] = $now;
return false;
}
private function acquireWebCronLock(): bool
{
$statement = $this->db->query("SELECT GET_LOCK('orderpro_web_cron_lock', 0)");
$value = $statement !== false ? $statement->fetchColumn() : false;
return (string) $value === '1';
}
private function releaseWebCronLock(): void
{
$this->db->query("DO RELEASE_LOCK('orderpro_web_cron_lock')");
}
}

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Modules\Cron;
final class CronJobType
{
public const PRODUCT_LINKS_HEALTH_CHECK = 'product_links_health_check';
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,
default => self::PRIORITY_NORMAL,
};
}
public static function maxAttemptsFor(string $jobType): int
{
return match (trim($jobType)) {
self::PRODUCT_LINKS_HEALTH_CHECK => 3,
default => 3,
};
}
}

View File

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

View File

@@ -0,0 +1,70 @@
<?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', 'marianek.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,
'subBrandName' => $defaultBrand,
'commonName' => $commonName,
'gpcCode' => $defaultGpcCode,
'netContent' => 1,
'netContentUnit' => 'szt',
'status' => 'ACT',
'targetMarket' => ['PL'],
'descriptionLanguage' => 'PL',
]);
$this->products->updateEan($productId, $newEan);
return ['ean' => $newEan];
}
}

View File

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

View File

@@ -0,0 +1,74 @@
<?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;
final class MarketplaceController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly MarketplaceRepository $marketplace
) {
}
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();
$offers = $this->marketplace->listLinkedOffersByIntegration($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,
'errorMessage' => (string) Flash::get('marketplace_error', ''),
], 'layouts/app');
return Response::html($html);
}
}

View File

@@ -0,0 +1,142 @@
<?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'] ?? ''),
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function listLinkedOffersByIntegration(int $integrationId): array
{
$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
)
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 <> ""
ORDER BY pcm.updated_at DESC, pcm.id DESC'
);
$statement->execute([
'lang' => 'pl',
'integration_id' => $integrationId,
'link_status' => 'active',
]);
$rows = $statement->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),
'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
);
}
}

View File

@@ -162,6 +162,21 @@ final class ChannelOffersRepository
]);
}
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
*/

View File

@@ -68,7 +68,7 @@ final class OfferImportService
$failed = 0;
$pages = 0;
$errors = [];
$now = date('Y-m-d H:i:s');
$syncStartedAt = date('Y-m-d H:i:s');
$page = 1;
$safePerPage = max(1, min(100, $perPage));
$safeMaxPages = max(1, min(500, $maxPages));
@@ -92,7 +92,7 @@ final class OfferImportService
$pages++;
foreach ($items as $item) {
$mapped = $this->mapExternalItem($item, $now);
$mapped = $this->mapExternalItem($item, $syncStartedAt);
if ($mapped === null) {
$failed++;
if (count($errors) < 3) {
@@ -140,6 +140,18 @@ final class OfferImportService
$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,

View File

@@ -23,10 +23,17 @@ final class ProductLinksRepository
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
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'
);
@@ -49,10 +56,17 @@ final class ProductLinksRepository
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
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'
);
@@ -332,6 +346,97 @@ final class ProductLinksRepository
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) {
@@ -382,6 +487,10 @@ final class ProductLinksRepository
'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'] ?? ''),
];

View File

@@ -147,12 +147,10 @@ final class ProductLinksService
try {
$this->pdo->beginTransaction();
$this->links->markAsUnlinked($mapId, $userId, 'inactive', 'unlinked');
$after = $this->links->findById($mapId);
if ($after === null) {
throw new \RuntimeException('Nie udalo sie odlaczyc wskazanego powiazania.');
$deleted = $this->links->deleteById($mapId);
if (!$deleted) {
throw new \RuntimeException('Nie udalo sie usunac wskazanego powiazania.');
}
$this->links->logEvent($mapId, 'unlinked', $existingMap, $after, $userId);
if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}

View File

@@ -78,7 +78,8 @@ final class ProductRepository
{
$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.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
@@ -158,11 +159,11 @@ final class ProductRepository
'INSERT INTO products (
uuid, type, sku, ean, status, promoted, vat, weight,
price_brutto, price_brutto_promo, price_netto, price_netto_promo,
quantity, producer_id, product_unit_id, created_at, updated_at
quantity, producer_id, producer_name, product_unit_id, custom_fields_json, created_at, updated_at
) VALUES (
:uuid, :type, :sku, :ean, :status, :promoted, :vat, :weight,
:price_brutto, :price_brutto_promo, :price_netto, :price_netto_promo,
:quantity, :producer_id, :product_unit_id, :created_at, :updated_at
:quantity, :producer_id, :producer_name, :product_unit_id, :custom_fields_json, :created_at, :updated_at
)'
);
$stmt->execute($payload);
@@ -172,10 +173,10 @@ final class ProductRepository
$translationStmt = $this->pdo->prepare(
'INSERT INTO product_translations (
product_id, lang, name, short_description, description,
meta_title, meta_description, meta_keywords, seo_link, created_at, updated_at
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, :created_at, :updated_at
:meta_title, :meta_description, :meta_keywords, :seo_link, :security_information, :created_at, :updated_at
)'
);
@@ -205,7 +206,9 @@ final class ProductRepository
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'
);
@@ -214,10 +217,10 @@ final class ProductRepository
$translationUpsert = $this->pdo->prepare(
'INSERT INTO product_translations (
product_id, lang, name, short_description, description,
meta_title, meta_description, meta_keywords, seo_link, created_at, updated_at
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, :created_at, :updated_at
: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),
@@ -226,6 +229,7 @@ final class ProductRepository
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));
@@ -304,7 +308,7 @@ final class ProductRepository
{
$variantStmt = $this->pdo->prepare(
'SELECT id, product_id, permutation_hash, sku, ean, status,
price_brutto, price_brutto_promo, price_netto, price_netto_promo, weight,
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
@@ -339,6 +343,7 @@ final class ProductRepository
'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' => [],
@@ -351,9 +356,11 @@ final class ProductRepository
$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))) . ')
@@ -378,6 +385,7 @@ final class ProductRepository
$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'] ?? ''),
];
@@ -562,6 +570,7 @@ final class ProductRepository
return match ($sort) {
'name' => 'pt.name',
'sku' => 'p.sku',
'ean' => 'p.ean',
'price_brutto' => 'p.price_brutto',
'quantity' => 'p.quantity',
'status' => 'p.status',
@@ -613,6 +622,7 @@ final class ProductRepository
'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'] ?? ''),
@@ -621,8 +631,22 @@ final class ProductRepository
'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,
]);
}
}

View File

@@ -172,7 +172,9 @@ final class ProductService
'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,
];
@@ -186,6 +188,7 @@ final class ProductService
'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,
];
@@ -327,7 +330,9 @@ final class ProductService
'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'),
];
}

View File

@@ -12,6 +12,7 @@ use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use App\Modules\ProductLinks\ProductLinksService;
use App\Modules\Settings\IntegrationRepository;
use App\Modules\Products\ShopProExportService;
final class ProductsController
{
@@ -22,7 +23,9 @@ final class ProductsController
private readonly ProductRepository $products,
private readonly ProductService $service,
private readonly IntegrationRepository $integrations,
private readonly ProductLinksService $productLinks
private readonly ProductLinksService $productLinks,
private readonly ShopProExportService $shopProExport,
private readonly \App\Modules\GS1\GS1Service $gs1Service
) {
}
@@ -51,6 +54,7 @@ final class ProductsController
'activeMenu' => 'products',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'marketplaceIntegrations' => $this->marketplaceIntegrations(),
'shopProIntegrations' => $shopProIntegrations,
'tableList' => [
'list_key' => 'products',
@@ -67,6 +71,14 @@ final class ProductsController
'data-open-modal' => 'product-import-modal',
],
],
[
'type' => 'button',
'label' => $this->translator->get('products.actions.export_shoppro'),
'class' => 'btn btn--secondary',
'attrs' => [
'data-open-modal' => 'product-export-modal',
],
],
],
'filters' => [
[
@@ -106,6 +118,7 @@ final class ProductsController
'id' => 'ID',
'name' => $this->translator->get('products.fields.name'),
'sku' => 'SKU',
'ean' => 'EAN',
'price_brutto' => $this->translator->get('products.fields.price_brutto'),
'quantity' => $this->translator->get('products.fields.quantity'),
'status' => $this->translator->get('products.fields.status'),
@@ -138,12 +151,17 @@ final class ProductsController
['key' => 'id', 'label' => 'ID', 'sortable' => true, 'sort_key' => 'id'],
['key' => 'name', 'label' => $this->translator->get('products.fields.name'), 'raw' => true, 'sortable' => true, 'sort_key' => 'name'],
['key' => 'sku', 'label' => 'SKU', 'sortable' => true, 'sort_key' => 'sku'],
['key' => 'ean', 'label' => 'EAN', 'sortable' => true, 'sort_key' => 'ean'],
['key' => 'type_label', 'label' => $this->translator->get('products.fields.type')],
['key' => 'price_brutto', 'label' => $this->translator->get('products.fields.price_brutto'), 'sortable' => true, 'sort_key' => 'price_brutto'],
['key' => 'quantity', 'label' => $this->translator->get('products.fields.quantity'), 'sortable' => true, 'sort_key' => 'quantity'],
['key' => 'status_label', 'label' => $this->translator->get('products.fields.status'), 'raw' => true, 'sortable' => true, 'sort_key' => 'status'],
['key' => 'updated_at', 'label' => $this->translator->get('products.fields.updated_at'), 'sortable' => true, 'sort_key' => 'updated_at'],
],
'selectable' => true,
'select_name' => 'export_product_ids[]',
'select_value_key' => 'id',
'select_column_label' => $this->translator->get('products.export.select_column_label'),
'rows' => $rows,
'pagination' => [
'page' => (int) ($result['page'] ?? 1),
@@ -170,6 +188,7 @@ final class ProductsController
'activeMenu' => 'products',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'marketplaceIntegrations' => $this->marketplaceIntegrations(),
'form' => $this->formDataFromFlash(),
'errors' => (array) Flash::get('products_form_errors', []),
], 'layouts/app');
@@ -222,6 +241,7 @@ final class ProductsController
'activeMenu' => 'products',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'marketplaceIntegrations' => $this->marketplaceIntegrations(),
'productId' => $id,
'form' => $form,
'productImages' => $productImages,
@@ -254,6 +274,7 @@ final class ProductsController
'activeMenu' => 'products',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'marketplaceIntegrations' => $this->marketplaceIntegrations(),
'productId' => $id,
'product' => $product,
'productImages' => $productImages,
@@ -287,6 +308,7 @@ final class ProductsController
'activeMenu' => 'products',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'marketplaceIntegrations' => $this->marketplaceIntegrations(),
'productId' => $id,
'product' => $product,
'productLinks' => (array) ($linksData['links'] ?? []),
@@ -335,6 +357,96 @@ final class ProductsController
]);
}
public function exportShopPro(Request $request): Response
{
$csrfToken = (string) $request->input('_token', '');
if (!Csrf::validate($csrfToken)) {
Flash::set('products_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/products');
}
$integrationId = max(0, (int) $request->input('integration_id', 0));
$exportMode = (string) $request->input('export_mode', 'simple');
$selectedIds = $this->normalizeIntArray($request->input('export_product_ids', []));
if ($integrationId <= 0) {
Flash::set('products_error', $this->translator->get('products.export.flash.integration_required'));
return Response::redirect('/products');
}
if (!in_array($exportMode, ['simple', 'variant'], true)) {
Flash::set('products_error', $this->translator->get('products.export.flash.mode_invalid'));
return Response::redirect('/products');
}
if ($selectedIds === []) {
Flash::set('products_error', $this->translator->get('products.export.flash.no_products_selected'));
return Response::redirect('/products');
}
try {
$credentials = $this->integrations->findApiCredentials($integrationId);
} catch (\Throwable $exception) {
Flash::set('products_error', $this->translator->get('products.export.flash.failed') . ' ' . $exception->getMessage());
return Response::redirect('/products');
}
if ($credentials === null) {
Flash::set('products_error', $this->translator->get('products.export.flash.integration_not_found'));
return Response::redirect('/products');
}
$apiKey = (string) ($credentials['api_key'] ?? '');
if ($apiKey === '') {
Flash::set('products_error', $this->translator->get('products.export.flash.api_key_missing'));
return Response::redirect('/products');
}
$result = $this->shopProExport->exportProducts($selectedIds, $credentials, $exportMode, $this->auth->user());
$summary = $this->translator->get('products.export.flash.done', [
'exported' => (string) ($result['exported'] ?? 0),
'failed' => (string) ($result['failed'] ?? 0),
'mode' => $exportMode === 'variant'
? $this->translator->get('products.export.mode_variant')
: $this->translator->get('products.export.mode_simple'),
]);
$errors = is_array($result['errors'] ?? null) ? $result['errors'] : [];
if ($errors !== []) {
Flash::set('products_error', $summary . ' ' . implode(' | ', $errors));
} else {
Flash::set('products_success', $summary);
}
return Response::redirect('/products');
}
public function assignGs1Ean(Request $request): Response
{
$csrfToken = (string) $request->input('_token', '');
if (!Csrf::validate($csrfToken)) {
Flash::set('products_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/products');
}
$id = (int) $request->input('id', 0);
if ($id <= 0) {
Flash::set('products_error', $this->translator->get('products.flash.not_found'));
return Response::redirect('/products');
}
try {
$result = $this->gs1Service->assignEanToProduct($id);
Flash::set('products_success', $this->translator->get('products.gs1.ean_assigned', [
'ean' => $result['ean'],
]));
} catch (\Throwable $e) {
Flash::set('products_error', $this->translator->get('products.gs1.error') . ' ' . $e->getMessage());
}
return Response::redirect('/products/' . $id);
}
public function update(Request $request): Response
{
$csrfToken = (string) $request->input('_token', '');
@@ -663,6 +775,7 @@ final class ProductsController
'id' => $id,
'name' => $this->renderProductNameCell((string) ($row['name'] ?? ''), (string) ($row['main_image_path'] ?? '')),
'sku' => (string) ($row['sku'] ?? ''),
'ean' => (string) ($row['ean'] ?? ''),
'type_label' => $type === 'variant_parent'
? $this->translator->get('products.type.variant_parent')
: $this->translator->get('products.type.simple'),
@@ -1008,4 +1121,20 @@ final class ProductsController
'public_url' => $this->publicImageUrl($storagePath),
];
}
/**
* @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)
));
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -9,8 +9,10 @@ use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\Support\Logger;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use App\Modules\Cron\CronJobRepository;
use App\Modules\ProductLinks\OfferImportService;
use App\Modules\Products\ProductRepository;
use PDO;
@@ -26,8 +28,11 @@ final class SettingsController
private readonly IntegrationRepository $integrations,
private readonly ShopProClient $shopProClient,
private readonly OfferImportService $offerImportService,
private readonly CronJobRepository $cronJobs,
private readonly AppSettingsRepository $appSettings,
private readonly ProductRepository $products,
private readonly PDO $pdo
private readonly PDO $pdo,
private readonly ?Logger $logger = null
) {
}
@@ -41,6 +46,7 @@ final class SettingsController
'activeSettings' => 'database',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'marketplaceIntegrations' => $this->marketplaceIntegrations(),
'status' => $status,
'errorMessage' => (string) Flash::get('settings_error', ''),
'successMessage' => (string) Flash::get('settings_success', ''),
@@ -83,6 +89,115 @@ final class SettingsController
return Response::redirect('/settings/database');
}
public function cron(Request $request): Response
{
$runOnWebEnabled = false;
$webLimit = 5;
$errorMessage = (string) Flash::get('settings_error', '');
$successMessage = (string) Flash::get('settings_success', '');
try {
$runOnWebEnabled = $this->appSettings->getBool('cron_run_on_web', false);
$webLimit = max(1, min(100, $this->appSettings->getInt('cron_web_limit', 5)));
$schedules = $this->cronJobs->listSchedules(100);
$futureJobs = $this->cronJobs->listFutureJobs(150);
$pastJobs = $this->cronJobs->listPastJobs(150);
} catch (Throwable $exception) {
$schedules = [];
$futureJobs = [];
$pastJobs = [];
if ($errorMessage === '') {
$errorMessage = $this->translator->get('settings.cron.flash.load_failed') . ' ' . $exception->getMessage();
}
}
$html = $this->template->render('settings/cron', [
'title' => $this->translator->get('settings.cron.title'),
'activeMenu' => 'cron',
'activeSettings' => 'cron',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'marketplaceIntegrations' => $this->marketplaceIntegrations(),
'runOnWebEnabled' => $runOnWebEnabled,
'webCronLimit' => $webLimit,
'schedules' => $schedules,
'futureJobs' => $futureJobs,
'pastJobs' => $pastJobs,
'errorMessage' => $errorMessage,
'successMessage' => $successMessage,
], 'layouts/app');
return Response::html($html);
}
public function saveCronSettings(Request $request): Response
{
$csrfToken = (string) $request->input('_token', '');
if (!Csrf::validate($csrfToken)) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/settings/cron');
}
$runOnWeb = (string) $request->input('cron_run_on_web', '0') === '1';
$webLimit = max(1, min(100, (int) $request->input('cron_web_limit', 5)));
try {
$this->appSettings->set('cron_run_on_web', $runOnWeb ? '1' : '0');
$this->appSettings->set('cron_web_limit', (string) $webLimit);
Flash::set('settings_success', $this->translator->get('settings.cron.flash.saved'));
} catch (Throwable $exception) {
Flash::set('settings_error', $this->translator->get('settings.cron.flash.save_failed') . ' ' . $exception->getMessage());
}
return Response::redirect('/settings/cron');
}
public function gs1(Request $request): Response
{
$errorMessage = (string) Flash::get('settings_error', '');
$successMessage = (string) Flash::get('settings_success', '');
$html = $this->template->render('settings/gs1', [
'title' => $this->translator->get('settings.gs1.title'),
'activeMenu' => 'settings',
'activeSettings' => 'gs1',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'marketplaceIntegrations' => $this->marketplaceIntegrations(),
'gs1ApiLogin' => $this->appSettings->get('gs1_api_login', ''),
'gs1ApiPassword' => $this->appSettings->get('gs1_api_password', ''),
'gs1Prefix' => $this->appSettings->get('gs1_prefix', '590532390'),
'gs1DefaultBrand' => $this->appSettings->get('gs1_default_brand', 'marianek.pl'),
'gs1DefaultGpcCode' => $this->appSettings->get('gs1_default_gpc_code', '10008365'),
'errorMessage' => $errorMessage,
'successMessage' => $successMessage,
], 'layouts/app');
return Response::html($html);
}
public function gs1Save(Request $request): Response
{
$csrfToken = (string) $request->input('_token', '');
if (!Csrf::validate($csrfToken)) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/settings/gs1');
}
try {
$this->appSettings->set('gs1_api_login', trim((string) $request->input('gs1_api_login', '')));
$this->appSettings->set('gs1_api_password', trim((string) $request->input('gs1_api_password', '')));
$this->appSettings->set('gs1_prefix', trim((string) $request->input('gs1_prefix', '590532390')));
$this->appSettings->set('gs1_default_brand', trim((string) $request->input('gs1_default_brand', 'marianek.pl')));
$this->appSettings->set('gs1_default_gpc_code', trim((string) $request->input('gs1_default_gpc_code', '10008365')));
Flash::set('settings_success', $this->translator->get('settings.gs1.flash.saved'));
} catch (Throwable $exception) {
Flash::set('settings_error', $this->translator->get('settings.gs1.flash.save_failed') . ' ' . $exception->getMessage());
}
return Response::redirect('/settings/gs1');
}
public function integrations(Request $request): Response
{
$integrationId = max(0, (int) $request->input('id', 0));
@@ -112,6 +227,7 @@ final class SettingsController
'activeSettings' => 'integrations',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'marketplaceIntegrations' => $this->marketplaceIntegrations(),
'integrations' => $list,
'selectedIntegration' => $selected,
'recentTests' => $recentTests,
@@ -591,6 +707,20 @@ final class SettingsController
];
}
// DEBUG: log raw API response for GPSR + custom_fields
if ($this->logger !== null) {
$lang = $this->resolveProductLanguage($externalProduct);
$this->logger->info('import_debug_api_response', [
'external_product_id' => $externalProductId,
'has_languages_key' => array_key_exists('languages', $externalProduct),
'languages_keys' => is_array($externalProduct['languages'] ?? null) ? array_keys($externalProduct['languages']) : null,
'security_information_key_exists' => array_key_exists('security_information', $lang),
'security_information_value' => array_key_exists('security_information', $lang) ? $lang['security_information'] : '(klucz nieobecny w tablicy)',
'has_custom_fields_key' => array_key_exists('custom_fields', $externalProduct),
'custom_fields_count' => is_array($externalProduct['custom_fields'] ?? null) ? count($externalProduct['custom_fields']) : null,
]);
}
$sku = trim((string) ($externalProduct['sku'] ?? ''));
$ean = trim((string) ($externalProduct['ean'] ?? ''));
$integrationId = max(0, (int) ($credentials['id'] ?? 0));
@@ -609,6 +739,16 @@ final class SettingsController
}
$normalized = $this->normalizeExternalProductForLocalSave($externalProduct, $externalProductId);
// DEBUG: log what will be saved
if ($this->logger !== null) {
$this->logger->info('import_debug_normalized', [
'external_product_id' => $externalProductId,
'security_information' => $normalized['translation']['security_information'] ?? '(brak klucza)',
'custom_fields_json' => $normalized['product_create']['custom_fields_json'] ?? '(brak klucza)',
]);
}
$savedProductId = 0;
try {
@@ -689,6 +829,10 @@ final class SettingsController
? 'variant_parent'
: 'simple';
$customFields = is_array($externalProduct['custom_fields'] ?? null) && $externalProduct['custom_fields'] !== []
? json_encode($externalProduct['custom_fields'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
: null;
$common = [
'type' => $baseType,
'sku' => $sku !== '' ? $sku : null,
@@ -703,7 +847,9 @@ final class SettingsController
'price_netto_promo' => $this->nullableFloat($externalProduct['price_netto_promo'] ?? null, 2),
'quantity' => round((float) ($externalProduct['quantity'] ?? 0), 3),
'producer_id' => $this->nullableInt($externalProduct['producer_id'] ?? null),
'producer_name' => $this->nullableText($externalProduct['producer_name'] ?? null),
'product_unit_id' => $this->nullableInt($externalProduct['product_unit_id'] ?? null),
'custom_fields_json' => $customFields === false ? null : $customFields,
];
$productCreate = $common + [
@@ -715,6 +861,8 @@ final class SettingsController
'updated_at' => $now,
];
$securityInformation = trim((string) ($lang['security_information'] ?? ''));
$translation = [
'lang' => 'pl',
'name' => $name,
@@ -724,6 +872,7 @@ final class SettingsController
'meta_description' => $metaDescription !== '' ? $metaDescription : null,
'meta_keywords' => $metaKeywords !== '' ? $metaKeywords : null,
'seo_link' => $seoLink !== '' ? $seoLink : null,
'security_information' => $securityInformation !== '' ? $securityInformation : null,
'created_at' => $now,
'updated_at' => $now,
];
@@ -837,11 +986,11 @@ final class SettingsController
$insertVariantStmt = $this->pdo->prepare(
'INSERT INTO product_variants (
product_id, permutation_hash, sku, ean, status,
product_id, permutation_hash, sku, ean, status, stock_0_buy,
price_brutto, price_brutto_promo, price_netto, price_netto_promo, weight,
created_at, updated_at
) VALUES (
:product_id, :permutation_hash, :sku, :ean, :status,
:product_id, :permutation_hash, :sku, :ean, :status, :stock_0_buy,
:price_brutto, :price_brutto_promo, :price_netto, :price_netto_promo, :weight,
:created_at, :updated_at
)'
@@ -884,6 +1033,7 @@ final class SettingsController
'sku' => $variantSku,
'ean' => $this->nullableText($variant['ean'] ?? null),
'status' => 1,
'stock_0_buy' => ((int) ($variant['stock_0_buy'] ?? 0)) === 1 ? 1 : 0,
'price_brutto' => $this->nullableFloat($variant['price_brutto'] ?? null, 2),
'price_brutto_promo' => $this->nullableFloat($variant['price_brutto_promo'] ?? null, 2),
'price_netto' => $this->nullableFloat($variant['price_netto'] ?? null, 2),
@@ -1230,4 +1380,15 @@ final class SettingsController
$text = trim((string) $value);
return $text === '' ? null : $text;
}
/**
* @return array<int, array<string, mixed>>
*/
private function marketplaceIntegrations(): array
{
return array_values(array_filter(
$this->integrations->listByType('shoppro'),
static fn (array $row): bool => (bool) ($row['is_active'] ?? false)
));
}
}

View File

@@ -104,6 +104,383 @@ final class ShopProClient
];
}
/**
* @param array<string, mixed> $payload
* @return array{ok:bool,http_code:int|null,message:string,external_id:int}
*/
public function createProduct(string $baseUrl, string $apiKey, int $timeoutSeconds, array $payload): array
{
$normalizedBaseUrl = rtrim(trim($baseUrl), '/');
$endpointUrl = $normalizedBaseUrl . '/api.php?endpoint=products&action=create';
$response = $this->requestJson($endpointUrl, $apiKey, $timeoutSeconds, 'POST', $payload);
if (($response['ok'] ?? false) !== true) {
return [
'ok' => false,
'http_code' => $response['http_code'] ?? null,
'message' => (string) ($response['message'] ?? 'Nie mozna utworzyc produktu w shopPRO.'),
'external_id' => 0,
];
}
$data = is_array($response['data'] ?? null) ? $response['data'] : [];
$externalId = (int) ($data['id'] ?? 0);
if ($externalId <= 0) {
return [
'ok' => false,
'http_code' => $response['http_code'] ?? null,
'message' => 'shopPRO nie zwrocil ID nowo utworzonego produktu.',
'external_id' => 0,
];
}
return [
'ok' => true,
'http_code' => $response['http_code'] ?? null,
'message' => '',
'external_id' => $externalId,
];
}
/**
* @param array<string, mixed> $payload
* @return array{ok:bool,http_code:int|null,message:string}
*/
public function updateProduct(
string $baseUrl,
string $apiKey,
int $timeoutSeconds,
int $externalProductId,
array $payload
): array {
if ($externalProductId <= 0) {
return [
'ok' => false,
'http_code' => null,
'message' => 'Niepoprawne ID produktu do aktualizacji.',
];
}
$normalizedBaseUrl = rtrim(trim($baseUrl), '/');
$query = http_build_query([
'endpoint' => 'products',
'action' => 'update',
'id' => $externalProductId,
]);
$endpointUrl = $normalizedBaseUrl . '/api.php?' . $query;
$response = $this->requestJson($endpointUrl, $apiKey, $timeoutSeconds, 'PUT', $payload);
return [
'ok' => ($response['ok'] ?? false) === true,
'http_code' => $response['http_code'] ?? null,
'message' => (string) ($response['message'] ?? ''),
];
}
/**
* @return array{ok:bool,http_code:int|null,message:string,src:string}
*/
public function uploadProductImage(
string $baseUrl,
string $apiKey,
int $timeoutSeconds,
int $externalProductId,
string $fileName,
string $contentBase64,
?string $alt = null,
?int $position = null
): array {
if ($externalProductId <= 0) {
return [
'ok' => false,
'http_code' => null,
'message' => 'Niepoprawne ID produktu do uploadu zdjecia.',
'src' => '',
];
}
$safeFileName = trim($fileName);
if ($safeFileName === '' || trim($contentBase64) === '') {
return [
'ok' => false,
'http_code' => null,
'message' => 'Brak nazwy pliku lub zawartosci obrazu do uploadu.',
'src' => '',
];
}
$normalizedBaseUrl = rtrim(trim($baseUrl), '/');
$endpointUrl = $normalizedBaseUrl . '/api.php?endpoint=products&action=upload_image';
$payload = [
'id' => $externalProductId,
'file_name' => $safeFileName,
'content_base64' => $contentBase64,
];
if ($alt !== null) {
$payload['alt'] = $alt;
}
if ($position !== null && $position >= 0) {
$payload['o'] = $position;
}
$response = $this->requestJson($endpointUrl, $apiKey, $timeoutSeconds, 'POST', $payload);
if (($response['ok'] ?? false) !== true) {
return [
'ok' => false,
'http_code' => $response['http_code'] ?? null,
'message' => (string) ($response['message'] ?? 'Nie mozna przeslac zdjecia produktu do shopPRO.'),
'src' => '',
];
}
$data = is_array($response['data'] ?? null) ? $response['data'] : [];
return [
'ok' => true,
'http_code' => $response['http_code'] ?? null,
'message' => '',
'src' => (string) ($data['src'] ?? ''),
];
}
/**
* @return array{ok:bool,http_code:int|null,message:string,variants:array<int,array<string,mixed>>}
*/
public function fetchProductVariants(
string $baseUrl,
string $apiKey,
int $timeoutSeconds,
int $externalProductId
): array {
if ($externalProductId <= 0) {
return [
'ok' => false,
'http_code' => null,
'message' => 'Niepoprawne ID produktu do pobrania wariantow.',
'variants' => [],
];
}
$normalizedBaseUrl = rtrim(trim($baseUrl), '/');
$query = http_build_query([
'endpoint' => 'products',
'action' => 'variants',
'id' => $externalProductId,
]);
$endpointUrl = $normalizedBaseUrl . '/api.php?' . $query;
$response = $this->requestJson($endpointUrl, $apiKey, $timeoutSeconds);
if (($response['ok'] ?? false) !== true) {
return [
'ok' => false,
'http_code' => $response['http_code'] ?? null,
'message' => (string) ($response['message'] ?? 'Nie mozna pobrac wariantow produktu z shopPRO.'),
'variants' => [],
];
}
$data = is_array($response['data'] ?? null) ? $response['data'] : [];
$variants = isset($data['variants']) && is_array($data['variants']) ? $data['variants'] : [];
return [
'ok' => true,
'http_code' => $response['http_code'] ?? null,
'message' => '',
'variants' => $variants,
];
}
/**
* @param array<string, mixed> $payload
* @return array{ok:bool,http_code:int|null,message:string,external_variant_id:int}
*/
public function createProductVariant(
string $baseUrl,
string $apiKey,
int $timeoutSeconds,
int $externalProductId,
array $payload
): array {
if ($externalProductId <= 0) {
return [
'ok' => false,
'http_code' => null,
'message' => 'Niepoprawne ID produktu do tworzenia wariantu.',
'external_variant_id' => 0,
];
}
$normalizedBaseUrl = rtrim(trim($baseUrl), '/');
$query = http_build_query([
'endpoint' => 'products',
'action' => 'create_variant',
'id' => $externalProductId,
]);
$endpointUrl = $normalizedBaseUrl . '/api.php?' . $query;
$response = $this->requestJson($endpointUrl, $apiKey, $timeoutSeconds, 'POST', $payload);
if (($response['ok'] ?? false) !== true) {
return [
'ok' => false,
'http_code' => $response['http_code'] ?? null,
'message' => (string) ($response['message'] ?? 'Nie mozna utworzyc wariantu w shopPRO.'),
'external_variant_id' => 0,
];
}
$data = is_array($response['data'] ?? null) ? $response['data'] : [];
$externalVariantId = (int) ($data['id'] ?? 0);
if ($externalVariantId <= 0) {
return [
'ok' => false,
'http_code' => $response['http_code'] ?? null,
'message' => 'shopPRO nie zwrocil ID nowo utworzonego wariantu.',
'external_variant_id' => 0,
];
}
return [
'ok' => true,
'http_code' => $response['http_code'] ?? null,
'message' => '',
'external_variant_id' => $externalVariantId,
];
}
/**
* @param array<string, mixed> $payload
* @return array{ok:bool,http_code:int|null,message:string}
*/
public function updateProductVariant(
string $baseUrl,
string $apiKey,
int $timeoutSeconds,
int $externalVariantId,
array $payload
): array {
if ($externalVariantId <= 0) {
return [
'ok' => false,
'http_code' => null,
'message' => 'Niepoprawne ID wariantu do aktualizacji.',
];
}
$normalizedBaseUrl = rtrim(trim($baseUrl), '/');
$query = http_build_query([
'endpoint' => 'products',
'action' => 'update_variant',
'id' => $externalVariantId,
]);
$endpointUrl = $normalizedBaseUrl . '/api.php?' . $query;
$response = $this->requestJson($endpointUrl, $apiKey, $timeoutSeconds, 'PUT', $payload);
return [
'ok' => ($response['ok'] ?? false) === true,
'http_code' => $response['http_code'] ?? null,
'message' => (string) ($response['message'] ?? ''),
];
}
/**
* @return array{ok:bool,http_code:int|null,message:string,attribute_id:int,created:bool}
*/
public function ensureAttribute(
string $baseUrl,
string $apiKey,
int $timeoutSeconds,
string $name,
int $type = 0,
string $lang = 'pl'
): array {
$normalizedBaseUrl = rtrim(trim($baseUrl), '/');
$endpointUrl = $normalizedBaseUrl . '/api.php?endpoint=dictionaries&action=ensure_attribute';
$payload = [
'name' => trim($name),
'type' => $type,
'lang' => trim($lang) !== '' ? trim($lang) : 'pl',
];
$response = $this->requestJson($endpointUrl, $apiKey, $timeoutSeconds, 'POST', $payload);
if (($response['ok'] ?? false) !== true) {
return [
'ok' => false,
'http_code' => $response['http_code'] ?? null,
'message' => (string) ($response['message'] ?? 'Nie mozna dopasowac/utworzyc atrybutu w shopPRO.'),
'attribute_id' => 0,
'created' => false,
];
}
$data = is_array($response['data'] ?? null) ? $response['data'] : [];
$attributeId = (int) ($data['id'] ?? 0);
if ($attributeId <= 0) {
return [
'ok' => false,
'http_code' => $response['http_code'] ?? null,
'message' => 'shopPRO nie zwrocil poprawnego ID atrybutu.',
'attribute_id' => 0,
'created' => false,
];
}
return [
'ok' => true,
'http_code' => $response['http_code'] ?? null,
'message' => '',
'attribute_id' => $attributeId,
'created' => !empty($data['created']),
];
}
/**
* @return array{ok:bool,http_code:int|null,message:string,value_id:int,created:bool}
*/
public function ensureAttributeValue(
string $baseUrl,
string $apiKey,
int $timeoutSeconds,
int $attributeId,
string $name,
string $lang = 'pl'
): array {
$normalizedBaseUrl = rtrim(trim($baseUrl), '/');
$endpointUrl = $normalizedBaseUrl . '/api.php?endpoint=dictionaries&action=ensure_attribute_value';
$payload = [
'attribute_id' => max(0, $attributeId),
'name' => trim($name),
'lang' => trim($lang) !== '' ? trim($lang) : 'pl',
];
$response = $this->requestJson($endpointUrl, $apiKey, $timeoutSeconds, 'POST', $payload);
if (($response['ok'] ?? false) !== true) {
return [
'ok' => false,
'http_code' => $response['http_code'] ?? null,
'message' => (string) ($response['message'] ?? 'Nie mozna dopasowac/utworzyc wartosci atrybutu w shopPRO.'),
'value_id' => 0,
'created' => false,
];
}
$data = is_array($response['data'] ?? null) ? $response['data'] : [];
$valueId = (int) ($data['id'] ?? 0);
if ($valueId <= 0) {
return [
'ok' => false,
'http_code' => $response['http_code'] ?? null,
'message' => 'shopPRO nie zwrocil poprawnego ID wartosci atrybutu.',
'value_id' => 0,
'created' => false,
];
}
return [
'ok' => true,
'http_code' => $response['http_code'] ?? null,
'message' => '',
'value_id' => $valueId,
'created' => !empty($data['created']),
];
}
/**
* @return array{ok:bool,status:string,http_code:int|null,message:string,endpoint_url:string,tested_at:string}
*/
@@ -152,15 +529,83 @@ final class ShopProClient
}
/**
* @return array{ok:bool,http_code:int|null,message:string,producer_id:int,created:bool}
*/
public function ensureProducer(
string $baseUrl,
string $apiKey,
int $timeoutSeconds,
string $name
): array {
$normalizedBaseUrl = rtrim(trim($baseUrl), '/');
$endpointUrl = $normalizedBaseUrl . '/api.php?endpoint=dictionaries&action=ensure_producer';
$payload = ['name' => trim($name)];
$response = $this->requestJson($endpointUrl, $apiKey, $timeoutSeconds, 'POST', $payload);
if (($response['ok'] ?? false) !== true) {
return [
'ok' => false,
'http_code' => $response['http_code'] ?? null,
'message' => (string) ($response['message'] ?? 'Nie mozna dopasowac/utworzyc producenta w shopPRO.'),
'producer_id' => 0,
'created' => false,
];
}
$data = is_array($response['data'] ?? null) ? $response['data'] : [];
$producerId = (int) ($data['id'] ?? 0);
if ($producerId <= 0) {
return [
'ok' => false,
'http_code' => $response['http_code'] ?? null,
'message' => 'shopPRO nie zwrocil poprawnego ID producenta.',
'producer_id' => 0,
'created' => false,
];
}
return [
'ok' => true,
'http_code' => $response['http_code'] ?? null,
'message' => '',
'producer_id' => $producerId,
'created' => !empty($data['created']),
];
}
/**
* @param array<string, mixed>|null $jsonBody
* @return array{ok:bool,http_code:int|null,message:string,payload?:array<string,mixed>,data?:mixed}
*/
private function requestJson(string $endpointUrl, string $apiKey, int $timeoutSeconds): array
{
private function requestJson(
string $endpointUrl,
string $apiKey,
int $timeoutSeconds,
string $method = 'GET',
?array $jsonBody = null
): array {
$timeout = max(3, min(60, $timeoutSeconds));
$normalizedMethod = strtoupper(trim($method));
if ($normalizedMethod === '') {
$normalizedMethod = 'GET';
}
if (function_exists('curl_init')) {
[$httpCode, $body, $transportError, $contentType] = $this->requestByCurl($endpointUrl, $apiKey, $timeout);
[$httpCode, $body, $transportError, $contentType] = $this->requestByCurl(
$endpointUrl,
$apiKey,
$timeout,
$normalizedMethod,
$jsonBody
);
} else {
[$httpCode, $body, $transportError, $contentType] = $this->requestByStream($endpointUrl, $apiKey, $timeout);
[$httpCode, $body, $transportError, $contentType] = $this->requestByStream(
$endpointUrl,
$apiKey,
$timeout,
$normalizedMethod,
$jsonBody
);
}
if ($transportError !== '') {
@@ -228,34 +673,57 @@ final class ShopProClient
}
/**
* @param array<string, mixed>|null $jsonBody
* @return array{0:int|null,1:string,2:string,3:string}
*/
private function requestByCurl(string $url, string $apiKey, int $timeoutSeconds): array
{
private function requestByCurl(
string $url,
string $apiKey,
int $timeoutSeconds,
string $method,
?array $jsonBody
): array {
$curl = curl_init($url);
if ($curl === false) {
return [null, '', 'Nie mozna zainicjalizowac cURL.'];
return [null, '', 'Nie mozna zainicjalizowac cURL.', ''];
}
$requestBody = null;
if ($jsonBody !== null && $method !== 'GET') {
$encoded = json_encode($jsonBody, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
return [null, '', 'Nie mozna zakodowac payload JSON.', ''];
}
$requestBody = $encoded;
}
$headers = [
'X-Api-Key: ' . $apiKey,
'Accept: application/json',
];
if ($requestBody !== null) {
$headers[] = 'Content-Type: application/json';
}
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'X-Api-Key: ' . $apiKey,
'Accept: application/json',
],
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => $timeoutSeconds,
CURLOPT_CONNECTTIMEOUT => max(2, min(10, $timeoutSeconds)),
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);
$contentType = (string) curl_getinfo($curl, CURLINFO_CONTENT_TYPE);
$error = curl_error($curl);
curl_close($curl);
return [
$httpCode > 0 ? $httpCode : null,
@@ -266,14 +734,32 @@ final class ShopProClient
}
/**
* @param array<string, mixed>|null $jsonBody
* @return array{0:int|null,1:string,2:string,3:string}
*/
private function requestByStream(string $url, string $apiKey, int $timeoutSeconds): array
{
private function requestByStream(
string $url,
string $apiKey,
int $timeoutSeconds,
string $method,
?array $jsonBody
): array {
$requestBody = null;
$headers = "X-Api-Key: {$apiKey}\r\nAccept: application/json\r\n";
if ($jsonBody !== null && $method !== 'GET') {
$encoded = json_encode($jsonBody, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
return [null, '', 'Nie mozna zakodowac payload JSON.', ''];
}
$requestBody = $encoded;
$headers .= "Content-Type: application/json\r\n";
}
$context = stream_context_create([
'http' => [
'method' => 'GET',
'header' => "X-Api-Key: {$apiKey}\r\nAccept: application/json\r\n",
'method' => $method,
'header' => $headers,
'content' => $requestBody ?? '',
'timeout' => $timeoutSeconds,
'ignore_errors' => true,
'follow_location' => 1,

View File

@@ -10,6 +10,7 @@ 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;
final class UsersController
{
@@ -17,7 +18,8 @@ final class UsersController
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly UserRepository $users
private readonly UserRepository $users,
private readonly IntegrationRepository $integrations
) {
}
@@ -38,6 +40,10 @@ final class UsersController
'activeMenu' => 'users',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'marketplaceIntegrations' => array_values(array_filter(
$this->integrations->listByType('shoppro'),
static fn (array $row): bool => (bool) ($row['is_active'] ?? false)
)),
'tableList' => [
'list_key' => 'users',
'base_path' => '/users',