Add initial HTML template for MojeGS1 application with Cookiebot and Google Analytics integration
This commit is contained in:
@@ -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')");
|
||||
}
|
||||
}
|
||||
|
||||
200
src/Modules/Cron/CronJobProcessor.php
Normal file
200
src/Modules/Cron/CronJobProcessor.php
Normal 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));
|
||||
}
|
||||
}
|
||||
517
src/Modules/Cron/CronJobRepository.php
Normal file
517
src/Modules/Cron/CronJobRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
29
src/Modules/Cron/CronJobType.php
Normal file
29
src/Modules/Cron/CronJobType.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
140
src/Modules/Cron/ProductLinksHealthCheckHandler.php
Normal file
140
src/Modules/Cron/ProductLinksHealthCheckHandler.php
Normal 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;
|
||||
}
|
||||
}
|
||||
70
src/Modules/GS1/GS1Service.php
Normal file
70
src/Modules/GS1/GS1Service.php
Normal 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];
|
||||
}
|
||||
}
|
||||
211
src/Modules/GS1/MojeGS1Client.php
Normal file
211
src/Modules/GS1/MojeGS1Client.php
Normal 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;
|
||||
}
|
||||
}
|
||||
74
src/Modules/Marketplace/MarketplaceController.php
Normal file
74
src/Modules/Marketplace/MarketplaceController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
142
src/Modules/Marketplace/MarketplaceRepository.php
Normal file
142
src/Modules/Marketplace/MarketplaceRepository.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'] ?? ''),
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1177
src/Modules/Products/ShopProExportService.php
Normal file
1177
src/Modules/Products/ShopProExportService.php
Normal file
File diff suppressed because it is too large
Load Diff
70
src/Modules/Settings/AppSettingsRepository.php
Normal file
70
src/Modules/Settings/AppSettingsRepository.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user