feat: add Shoppro payment status synchronization service

- Implemented ShopproPaymentStatusSyncService to handle payment status synchronization between Shoppro and Orderpro.
- Added methods for resolving watched status codes, finding candidate orders, and syncing individual order payments.
- Introduced ShopproStatusMappingRepository for managing status mappings between Shoppro and Orderpro.
- Created ShopproStatusSyncService to facilitate synchronization of order statuses from Shoppro to Orderpro.
This commit is contained in:
2026-03-08 20:41:10 +01:00
parent 3ba6202770
commit af052e1ff5
50 changed files with 6110 additions and 2602 deletions

View File

@@ -18,6 +18,9 @@ use App\Modules\Cron\AllegroStatusSyncHandler;
use App\Modules\Cron\AllegroTokenRefreshHandler;
use App\Modules\Cron\CronRepository;
use App\Modules\Cron\CronRunner;
use App\Modules\Cron\ShopproOrdersImportHandler;
use App\Modules\Cron\ShopproPaymentStatusSyncHandler;
use App\Modules\Cron\ShopproStatusSyncHandler;
use App\Modules\Orders\OrderImportRepository;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\AllegroApiClient;
@@ -29,6 +32,12 @@ use App\Modules\Settings\AllegroOAuthClient;
use App\Modules\Settings\AllegroStatusSyncService;
use App\Modules\Settings\AllegroStatusMappingRepository;
use App\Modules\Settings\OrderStatusRepository;
use App\Modules\Settings\ShopproApiClient;
use App\Modules\Settings\ShopproIntegrationsRepository;
use App\Modules\Settings\ShopproOrdersSyncService;
use App\Modules\Settings\ShopproPaymentStatusSyncService;
use App\Modules\Settings\ShopproStatusSyncService;
use App\Modules\Settings\ShopproStatusMappingRepository;
use App\Modules\Users\UserRepository;
use Throwable;
use PDO;
@@ -282,6 +291,33 @@ final class Application
$apiClient,
$orderImportService
);
$shopproSyncService = new ShopproOrdersSyncService(
new ShopproIntegrationsRepository(
$this->db,
(string) $this->config('app.integrations.secret', '')
),
new AllegroOrderSyncStateRepository($this->db),
new ShopproApiClient(),
new OrderImportRepository($this->db),
new ShopproStatusMappingRepository($this->db),
new OrdersRepository($this->db)
);
$shopproStatusSyncService = new ShopproStatusSyncService(
new ShopproIntegrationsRepository(
$this->db,
(string) $this->config('app.integrations.secret', '')
),
$shopproSyncService
);
$shopproPaymentSyncService = new ShopproPaymentStatusSyncService(
new ShopproIntegrationsRepository(
$this->db,
(string) $this->config('app.integrations.secret', '')
),
new ShopproApiClient(),
new OrdersRepository($this->db),
$this->db
);
$runner = new CronRunner(
$repository,
@@ -301,6 +337,15 @@ final class Application
$this->db
)
),
'shoppro_orders_import' => new ShopproOrdersImportHandler(
$shopproSyncService
),
'shoppro_order_status_sync' => new ShopproStatusSyncHandler(
$shopproStatusSyncService
),
'shoppro_payment_status_sync' => new ShopproPaymentStatusSyncHandler(
$shopproPaymentSyncService
),
]
);
$runner->run($webLimit);

View File

@@ -102,17 +102,19 @@ final class CronRepository
/**
* @return array<int, array<string, mixed>>
*/
public function listPastJobs(int $limit = 50): array
public function listPastJobs(int $limit = 50, int $offset = 0): array
{
$safeLimit = max(1, min(200, $limit));
$safeOffset = max(0, $offset);
$statement = $this->pdo->prepare(
'SELECT id, job_type, status, priority, attempts, max_attempts, scheduled_at, started_at, completed_at, last_error, created_at
FROM cron_jobs
WHERE status IN ("completed", "failed", "cancelled")
ORDER BY completed_at DESC, id DESC
LIMIT :limit'
LIMIT :limit OFFSET :offset'
);
$statement->bindValue(':limit', $safeLimit, PDO::PARAM_INT);
$statement->bindValue(':offset', $safeOffset, PDO::PARAM_INT);
$statement->execute();
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
@@ -122,6 +124,18 @@ final class CronRepository
return array_map(fn (array $row): array => $this->normalizeJobRow($row), $rows);
}
public function countPastJobs(): int
{
$statement = $this->pdo->query(
'SELECT COUNT(*)
FROM cron_jobs
WHERE status IN ("completed", "failed", "cancelled")'
);
$value = $statement !== false ? $statement->fetchColumn() : 0;
return max(0, (int) $value);
}
/**
* @return array<int, array<string, mixed>>
*/

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Modules\Cron;
use App\Modules\Settings\ShopproOrdersSyncService;
final class ShopproOrdersImportHandler
{
public function __construct(private readonly ShopproOrdersSyncService $syncService)
{
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function handle(array $payload): array
{
return $this->syncService->sync([
'max_pages' => (int) ($payload['max_pages'] ?? 3),
'page_limit' => (int) ($payload['page_limit'] ?? 50),
'max_orders' => (int) ($payload['max_orders'] ?? 200),
]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Modules\Cron;
use App\Modules\Settings\ShopproPaymentStatusSyncService;
final class ShopproPaymentStatusSyncHandler
{
public function __construct(private readonly ShopproPaymentStatusSyncService $syncService)
{
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function handle(array $payload): array
{
return $this->syncService->sync([
'per_integration_limit' => (int) ($payload['per_integration_limit'] ?? 100),
]);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Modules\Cron;
use App\Modules\Settings\ShopproStatusSyncService;
final class ShopproStatusSyncHandler
{
public function __construct(private readonly ShopproStatusSyncService $syncService)
{
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function handle(array $payload): array
{
return $this->syncService->sync();
}
}

View File

@@ -538,6 +538,7 @@ final class OrdersController
private function shippingHtml(string $deliveryMethod, int $shipments, int $documents): string
{
$deliveryMethod = trim(html_entity_decode(strip_tags($deliveryMethod), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
$html = '<div class="orders-mini">';
if ($deliveryMethod !== '' && !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $deliveryMethod)) {
$html .= '<div class="orders-mini__delivery">' . htmlspecialchars($deliveryMethod, ENT_QUOTES, 'UTF-8') . '</div>';

View File

@@ -420,9 +420,10 @@ final class OrdersRepository
$addresses = [];
}
$itemsMediaSql = $this->resolvedMediaUrlSql('oi');
$itemsMediaSql = $this->resolvedMediaUrlSql('oi', 'o.source');
$itemsStmt = $this->pdo->prepare('SELECT oi.*, ' . $itemsMediaSql . ' AS resolved_media_url
FROM order_items oi
INNER JOIN orders o ON o.id = oi.order_id
WHERE oi.order_id = :order_id
ORDER BY oi.sort_order ASC, oi.id ASC');
$itemsStmt->execute(['order_id' => $orderId]);
@@ -513,9 +514,10 @@ final class OrdersRepository
$placeholders = implode(',', array_fill(0, count($cleanIds), '?'));
try {
$resolvedMediaSql = $this->resolvedMediaUrlSql('oi');
$resolvedMediaSql = $this->resolvedMediaUrlSql('oi', 'o.source');
$sql = 'SELECT oi.order_id, oi.original_name, oi.quantity, ' . $resolvedMediaSql . ' AS media_url, oi.sort_order, oi.id
FROM order_items oi
INNER JOIN orders o ON o.id = oi.order_id
WHERE oi.order_id IN (' . $placeholders . ')
ORDER BY oi.order_id ASC, oi.sort_order ASC, oi.id ASC';
$stmt = $this->pdo->prepare($sql);
@@ -574,7 +576,7 @@ final class OrdersRepository
. ')';
}
private function resolvedMediaUrlSql(string $itemAlias): string
private function resolvedMediaUrlSql(string $itemAlias, string $sourceAlias = '"allegro"'): string
{
if (!$this->canResolveMappedMedia()) {
return 'COALESCE(NULLIF(TRIM(' . $itemAlias . '.media_url), ""), "")';
@@ -587,7 +589,7 @@ final class OrdersRepository
FROM product_channel_map pcm
INNER JOIN sales_channels sc ON sc.id = pcm.channel_id
INNER JOIN product_images pi ON pi.product_id = pcm.product_id
WHERE LOWER(sc.code) = "allegro"
WHERE LOWER(sc.code) = LOWER(' . $sourceAlias . ')
AND (
pcm.external_product_id = ' . $itemAlias . '.external_item_id
OR pcm.external_product_id = ' . $itemAlias . '.source_product_id

View File

@@ -111,34 +111,35 @@ final class AllegroIntegrationController
public function save(Request $request): Response
{
$csrfError = $this->validateCsrf((string) $request->input('_token', ''));
if ($csrfError !== null) {
return $csrfError;
$redirectTo = $this->resolveRedirectPath((string) $request->input('return_to', '/settings/integrations/allegro'));
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirectTo);
}
$environment = trim((string) $request->input('environment', 'sandbox'));
if (!in_array($environment, ['sandbox', 'production'], true)) {
Flash::set('settings_error', $this->translator->get('settings.allegro.validation.environment_invalid'));
return Response::redirect('/settings/integrations/allegro');
return Response::redirect($redirectTo);
}
$clientId = trim((string) $request->input('client_id', ''));
if ($clientId !== '' && mb_strlen($clientId) > 128) {
Flash::set('settings_error', $this->translator->get('settings.allegro.validation.client_id_too_long'));
return Response::redirect('/settings/integrations/allegro');
return Response::redirect($redirectTo);
}
$redirectUriInput = trim((string) $request->input('redirect_uri', ''));
$redirectUri = $redirectUriInput !== '' ? $redirectUriInput : $this->defaultRedirectUri();
if (!$this->isValidHttpUrl($redirectUri)) {
Flash::set('settings_error', $this->translator->get('settings.allegro.validation.redirect_uri_invalid'));
return Response::redirect('/settings/integrations/allegro');
return Response::redirect($redirectTo);
}
$ordersFetchStartDate = trim((string) $request->input('orders_fetch_start_date', ''));
if ($ordersFetchStartDate !== '' && !$this->isValidDate($ordersFetchStartDate)) {
Flash::set('settings_error', $this->translator->get('settings.allegro.validation.orders_fetch_start_date_invalid'));
return Response::redirect('/settings/integrations/allegro');
return Response::redirect($redirectTo);
}
try {
@@ -159,7 +160,7 @@ final class AllegroIntegrationController
);
}
return Response::redirect('/settings/integrations/allegro');
return Response::redirect($redirectTo);
}
public function saveImportSettings(Request $request): Response
@@ -649,6 +650,19 @@ final class AllegroIntegrationController
return Response::redirect('/settings/integrations/allegro');
}
private function resolveRedirectPath(string $candidate): string
{
$value = trim($candidate);
if ($value === '') {
return '/settings/integrations/allegro';
}
if (!str_starts_with($value, '/settings/integrations')) {
return '/settings/integrations/allegro';
}
return $value;
}
/**
* @return array<string, string>
*/

View File

@@ -10,11 +10,18 @@ use Throwable;
final class AllegroIntegrationRepository
{
private const DEFAULT_ENVIRONMENT = 'sandbox';
private const INTEGRATION_TYPE = 'allegro';
private readonly IntegrationsRepository $integrations;
private readonly IntegrationSecretCipher $cipher;
private ?bool $hasIntegrationIdColumn = null;
public function __construct(
private readonly PDO $pdo,
private readonly string $secret
) {
$this->integrations = new IntegrationsRepository($this->pdo);
$this->cipher = new IntegrationSecretCipher($this->secret);
}
/**
@@ -30,6 +37,7 @@ final class AllegroIntegrationRepository
return [
'environment' => $this->normalizeEnvironment((string) ($row['environment'] ?? self::DEFAULT_ENVIRONMENT)),
'integration_id' => (int) ($row['integration_id'] ?? 0),
'client_id' => trim((string) ($row['client_id'] ?? '')),
'has_client_secret' => trim((string) ($row['client_secret_encrypted'] ?? '')) !== '',
'redirect_uri' => trim((string) ($row['redirect_uri'] ?? '')),
@@ -60,6 +68,21 @@ final class AllegroIntegrationRepository
return $this->normalizeEnvironment(trim((string) ($row['setting_value'] ?? self::DEFAULT_ENVIRONMENT)));
}
public function getActiveIntegrationId(): int
{
$environment = $this->getActiveEnvironment();
$row = $this->fetchRowByEnv($environment);
$rowIntegrationId = (int) ($row['integration_id'] ?? 0);
if ($rowIntegrationId > 0) {
return $rowIntegrationId;
}
$integrationId = $this->ensureBaseIntegration($environment);
$this->assignIntegrationIdIfPossible($environment, $integrationId);
return $integrationId;
}
public function setActiveEnvironment(string $environment): void
{
$env = $this->normalizeEnvironment($environment);
@@ -86,7 +109,7 @@ final class AllegroIntegrationRepository
$clientSecret = trim((string) ($payload['client_secret'] ?? ''));
$clientSecretEncrypted = trim((string) ($current['client_secret_encrypted'] ?? ''));
if ($clientSecret !== '') {
$clientSecretEncrypted = (string) $this->encrypt($clientSecret);
$clientSecretEncrypted = (string) $this->cipher->encrypt($clientSecret);
}
$statement = $this->pdo->prepare(
@@ -123,7 +146,7 @@ final class AllegroIntegrationRepository
}
$clientId = trim((string) ($row['client_id'] ?? ''));
$clientSecret = $this->decrypt((string) ($row['client_secret_encrypted'] ?? ''));
$clientSecret = (string) $this->cipher->decrypt((string) ($row['client_secret_encrypted'] ?? ''));
$redirectUri = trim((string) ($row['redirect_uri'] ?? ''));
if ($clientId === '' || $clientSecret === '' || $redirectUri === '') {
return null;
@@ -160,8 +183,8 @@ final class AllegroIntegrationRepository
);
$statement->execute([
'environment' => $env,
'access_token_encrypted' => $this->encrypt($accessToken),
'refresh_token_encrypted' => $this->encrypt($refreshToken),
'access_token_encrypted' => $this->cipher->encrypt($accessToken),
'refresh_token_encrypted' => $this->cipher->encrypt($refreshToken),
'token_type' => $this->nullableString($tokenType),
'token_scope' => $this->nullableString($scope),
'token_expires_at' => $this->nullableString((string) $tokenExpiresAt),
@@ -180,8 +203,8 @@ final class AllegroIntegrationRepository
}
$clientId = trim((string) ($row['client_id'] ?? ''));
$clientSecret = $this->decrypt((string) ($row['client_secret_encrypted'] ?? ''));
$refreshToken = $this->decrypt((string) ($row['refresh_token_encrypted'] ?? ''));
$clientSecret = (string) $this->cipher->decrypt((string) ($row['client_secret_encrypted'] ?? ''));
$refreshToken = (string) $this->cipher->decrypt((string) ($row['refresh_token_encrypted'] ?? ''));
if ($clientId === '' || $clientSecret === '' || $refreshToken === '') {
return null;
}
@@ -206,9 +229,9 @@ final class AllegroIntegrationRepository
}
$clientId = trim((string) ($row['client_id'] ?? ''));
$clientSecret = $this->decrypt((string) ($row['client_secret_encrypted'] ?? ''));
$refreshToken = $this->decrypt((string) ($row['refresh_token_encrypted'] ?? ''));
$accessToken = $this->decrypt((string) ($row['access_token_encrypted'] ?? ''));
$clientSecret = (string) $this->cipher->decrypt((string) ($row['client_secret_encrypted'] ?? ''));
$refreshToken = (string) $this->cipher->decrypt((string) ($row['refresh_token_encrypted'] ?? ''));
$accessToken = (string) $this->cipher->decrypt((string) ($row['access_token_encrypted'] ?? ''));
if ($clientId === '' || $clientSecret === '' || $refreshToken === '') {
return null;
}
@@ -226,6 +249,26 @@ final class AllegroIntegrationRepository
private function ensureRow(string $environment): void
{
$env = $this->normalizeEnvironment($environment);
$integrationId = $this->ensureBaseIntegration($env);
if ($this->hasIntegrationIdColumn()) {
$statement = $this->pdo->prepare(
'INSERT INTO allegro_integration_settings (
integration_id, environment, orders_fetch_enabled, created_at, updated_at
) VALUES (
:integration_id, :environment, 0, NOW(), NOW()
)
ON DUPLICATE KEY UPDATE
updated_at = updated_at'
);
$statement->execute([
'integration_id' => $integrationId,
'environment' => $env,
]);
$this->assignIntegrationIdIfPossible($env, $integrationId);
return;
}
$statement = $this->pdo->prepare(
'INSERT INTO allegro_integration_settings (
environment, orders_fetch_enabled, created_at, updated_at
@@ -240,6 +283,79 @@ final class AllegroIntegrationRepository
]);
}
private function ensureBaseIntegration(string $environment): int
{
$env = $this->normalizeEnvironment($environment);
return $this->integrations->ensureIntegration(
self::INTEGRATION_TYPE,
$this->integrationNameForEnvironment($env),
$this->integrationBaseUrlForEnvironment($env),
20,
true
);
}
private function assignIntegrationIdIfPossible(string $environment, int $integrationId): void
{
if ($integrationId <= 0 || !$this->hasIntegrationIdColumn()) {
return;
}
try {
$statement = $this->pdo->prepare(
'UPDATE allegro_integration_settings
SET integration_id = :integration_id,
updated_at = NOW()
WHERE environment = :environment
AND (integration_id IS NULL OR integration_id = 0)'
);
$statement->execute([
'integration_id' => $integrationId,
'environment' => $this->normalizeEnvironment($environment),
]);
} catch (Throwable) {
return;
}
}
private function hasIntegrationIdColumn(): bool
{
if ($this->hasIntegrationIdColumn !== null) {
return $this->hasIntegrationIdColumn;
}
try {
$statement = $this->pdo->prepare(
"SELECT 1
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'allegro_integration_settings'
AND COLUMN_NAME = 'integration_id'
LIMIT 1"
);
$statement->execute();
$value = $statement->fetchColumn();
} catch (Throwable) {
$this->hasIntegrationIdColumn = false;
return false;
}
$this->hasIntegrationIdColumn = $value !== false;
return $this->hasIntegrationIdColumn;
}
private function integrationNameForEnvironment(string $environment): string
{
return $environment === 'production' ? 'Allegro Production' : 'Allegro Sandbox';
}
private function integrationBaseUrlForEnvironment(string $environment): string
{
return $environment === 'production'
? 'https://api.allegro.pl'
: 'https://api.allegro.pl.allegrosandbox.pl';
}
/**
* @return array<string, mixed>|null
*/
@@ -274,6 +390,7 @@ final class AllegroIntegrationRepository
{
return [
'environment' => $this->normalizeEnvironment($environment),
'integration_id' => 0,
'client_id' => '',
'has_client_secret' => false,
'redirect_uri' => '',
@@ -313,60 +430,4 @@ final class AllegroIntegrationRepository
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function encrypt(string $plainText): ?string
{
$value = trim($plainText);
if ($value === '') {
return null;
}
if ($this->secret === '') {
throw new RuntimeException('Brak INTEGRATIONS_SECRET do szyfrowania danych integracji.');
}
$encryptionKey = hash('sha256', 'enc|' . $this->secret, true);
$hmacKey = hash('sha256', 'auth|' . $this->secret, true);
$iv = random_bytes(16);
$cipherRaw = openssl_encrypt($value, 'AES-256-CBC', $encryptionKey, OPENSSL_RAW_DATA, $iv);
if ($cipherRaw === false) {
throw new RuntimeException('Nie udalo sie zaszyfrowac danych integracji.');
}
$mac = hash_hmac('sha256', $iv . $cipherRaw, $hmacKey, true);
return 'v1:' . base64_encode($iv . $mac . $cipherRaw);
}
private function decrypt(string $encryptedValue): string
{
$payload = trim($encryptedValue);
if ($payload === '') {
return '';
}
if ($this->secret === '') {
throw new RuntimeException('Brak INTEGRATIONS_SECRET do odszyfrowania danych integracji.');
}
if (!str_starts_with($payload, 'v1:')) {
return '';
}
$raw = base64_decode(substr($payload, 3), true);
if ($raw === false || strlen($raw) <= 48) {
return '';
}
$iv = substr($raw, 0, 16);
$mac = substr($raw, 16, 32);
$cipherRaw = substr($raw, 48);
$hmacKey = hash('sha256', 'auth|' . $this->secret, true);
$expectedMac = hash_hmac('sha256', $iv . $cipherRaw, $hmacKey, true);
if (!hash_equals($expectedMac, $mac)) {
return '';
}
$encryptionKey = hash('sha256', 'enc|' . $this->secret, true);
$plain = openssl_decrypt($cipherRaw, 'AES-256-CBC', $encryptionKey, OPENSSL_RAW_DATA, $iv);
return is_string($plain) ? $plain : '';
}
}

View File

@@ -252,7 +252,7 @@ final class AllegroOrderImportService
$fetchedAt = date('Y-m-d H:i:s');
$order = [
'integration_id' => null,
'integration_id' => $this->integrationRepository->getActiveIntegrationId(),
'source' => 'allegro',
'source_order_id' => $checkoutFormId,
'external_order_id' => $checkoutFormId,

View File

@@ -10,8 +10,6 @@ use Throwable;
final class AllegroOrdersSyncService
{
private const ALLEGRO_INTEGRATION_ID = 1;
public function __construct(
private readonly AllegroIntegrationRepository $integrationRepository,
private readonly AllegroOrderSyncStateRepository $syncStateRepository,
@@ -42,9 +40,14 @@ final class AllegroOrdersSyncService
];
}
$integrationId = $this->integrationRepository->getActiveIntegrationId();
if ($integrationId <= 0) {
throw new RuntimeException('Brak aktywnej integracji bazowej Allegro.');
}
$now = new DateTimeImmutable('now');
$state = $this->syncStateRepository->getState(self::ALLEGRO_INTEGRATION_ID);
$this->syncStateRepository->markRunStarted(self::ALLEGRO_INTEGRATION_ID, $now);
$state = $this->syncStateRepository->getState($integrationId);
$this->syncStateRepository->markRunStarted($integrationId, $now);
$maxPages = max(1, min(20, (int) ($options['max_pages'] ?? 5)));
$pageLimit = max(1, min(100, (int) ($options['page_limit'] ?? 50)));
@@ -171,7 +174,7 @@ final class AllegroOrdersSyncService
}
$this->syncStateRepository->markRunSuccess(
self::ALLEGRO_INTEGRATION_ID,
$integrationId,
new DateTimeImmutable('now'),
$latestProcessedUpdatedAt,
$latestProcessedSourceOrderId
@@ -181,7 +184,7 @@ final class AllegroOrdersSyncService
return $result;
} catch (Throwable $exception) {
$this->syncStateRepository->markRunFailed(
self::ALLEGRO_INTEGRATION_ID,
$integrationId,
new DateTimeImmutable('now'),
$exception->getMessage()
);

View File

@@ -42,15 +42,17 @@ final class ApaczkaIntegrationController
public function save(Request $request): Response
{
$redirectTo = $this->resolveRedirectPath((string) $request->input('return_to', '/settings/integrations/apaczka'));
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/settings/integrations/apaczka');
return Response::redirect($redirectTo);
}
$apiKey = trim((string) $request->input('api_key', ''));
if ($apiKey === '') {
Flash::set('settings_error', $this->translator->get('settings.apaczka.validation.api_key_required'));
return Response::redirect('/settings/integrations/apaczka');
return Response::redirect($redirectTo);
}
try {
@@ -65,6 +67,19 @@ final class ApaczkaIntegrationController
);
}
return Response::redirect('/settings/integrations/apaczka');
return Response::redirect($redirectTo);
}
private function resolveRedirectPath(string $candidate): string
{
$value = trim($candidate);
if ($value === '') {
return '/settings/integrations/apaczka';
}
if (!str_starts_with($value, '/settings/integrations')) {
return '/settings/integrations/apaczka';
}
return $value;
}
}

View File

@@ -4,15 +4,22 @@ declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
use RuntimeException;
use Throwable;
final class ApaczkaIntegrationRepository
{
private const INTEGRATION_TYPE = 'apaczka';
private const INTEGRATION_NAME = 'Apaczka';
private const INTEGRATION_BASE_URL = 'https://www.apaczka.pl';
private readonly IntegrationsRepository $integrations;
private readonly IntegrationSecretCipher $cipher;
public function __construct(
private readonly PDO $pdo,
private readonly string $secret
) {
$this->integrations = new IntegrationsRepository($this->pdo);
$this->cipher = new IntegrationSecretCipher($this->secret);
}
/**
@@ -20,13 +27,11 @@ final class ApaczkaIntegrationRepository
*/
public function getSettings(): array
{
$row = $this->fetchRow();
if ($row === null) {
return $this->defaultSettings();
}
$integrationId = $this->ensureBaseIntegration();
$integration = $this->integrations->findById($integrationId);
return [
'has_api_key' => trim((string) ($row['api_key_encrypted'] ?? '')) !== '',
'has_api_key' => trim((string) ($integration['api_key_encrypted'] ?? '')) !== '',
];
}
@@ -35,90 +40,25 @@ final class ApaczkaIntegrationRepository
*/
public function saveSettings(array $payload): void
{
$this->ensureRow();
$current = $this->fetchRow();
if ($current === null) {
throw new RuntimeException('Brak rekordu konfiguracji Apaczka.');
}
$integrationId = $this->ensureBaseIntegration();
$apiKey = trim((string) ($payload['api_key'] ?? ''));
$apiKeyEncrypted = trim((string) ($current['api_key_encrypted'] ?? ''));
if ($apiKey !== '') {
$apiKeyEncrypted = (string) $this->encrypt($apiKey);
if ($apiKey === '') {
return;
}
$statement = $this->pdo->prepare(
'UPDATE apaczka_integration_settings
SET api_key_encrypted = :api_key_encrypted,
updated_at = NOW()
WHERE id = 1'
$encrypted = $this->cipher->encrypt($apiKey);
$this->integrations->updateApiKeyEncrypted($integrationId, $encrypted);
}
private function ensureBaseIntegration(): int
{
return $this->integrations->ensureIntegration(
self::INTEGRATION_TYPE,
self::INTEGRATION_NAME,
self::INTEGRATION_BASE_URL,
20,
true
);
$statement->execute([
'api_key_encrypted' => $this->nullableString($apiKeyEncrypted),
]);
}
private function ensureRow(): void
{
$statement = $this->pdo->prepare(
'INSERT INTO apaczka_integration_settings (id, created_at, updated_at)
VALUES (1, NOW(), NOW())
ON DUPLICATE KEY UPDATE updated_at = VALUES(updated_at)'
);
$statement->execute();
}
/**
* @return array<string, mixed>|null
*/
private function fetchRow(): ?array
{
try {
$statement = $this->pdo->prepare('SELECT * FROM apaczka_integration_settings WHERE id = 1 LIMIT 1');
$statement->execute();
$row = $statement->fetch(PDO::FETCH_ASSOC);
} catch (Throwable) {
return null;
}
return is_array($row) ? $row : null;
}
/**
* @return array<string, mixed>
*/
private function defaultSettings(): array
{
return [
'has_api_key' => false,
];
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function encrypt(string $plainText): ?string
{
$value = trim($plainText);
if ($value === '') {
return null;
}
if ($this->secret === '') {
throw new RuntimeException('Brak INTEGRATIONS_SECRET do szyfrowania danych integracji.');
}
$encryptionKey = hash('sha256', 'enc|' . $this->secret, true);
$hmacKey = hash('sha256', 'auth|' . $this->secret, true);
$iv = random_bytes(16);
$cipherRaw = openssl_encrypt($value, 'AES-256-CBC', $encryptionKey, OPENSSL_RAW_DATA, $iv);
if ($cipherRaw === false) {
throw new RuntimeException('Nie udalo sie zaszyfrowac danych integracji.');
}
$mac = hash_hmac('sha256', $iv . $cipherRaw, $hmacKey, true);
return 'v1:' . base64_encode($iv . $mac . $cipherRaw);
}
}

View File

@@ -15,6 +15,8 @@ use Throwable;
final class CronSettingsController
{
private const PAST_JOBS_PER_PAGE = 25;
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
@@ -27,12 +29,20 @@ final class CronSettingsController
public function index(Request $request): Response
{
$pastPage = max(1, (int) $request->input('past_page', 1));
try {
$runOnWeb = $this->cronRepository->getBoolSetting('cron_run_on_web', $this->runOnWebDefault);
$webLimit = $this->cronRepository->getIntSetting('cron_web_limit', $this->webLimitDefault, 1, 100);
$schedules = $this->cronRepository->listSchedules();
$futureJobs = $this->cronRepository->listFutureJobs(60);
$pastJobs = $this->cronRepository->listPastJobs(60);
$pastJobsTotal = $this->cronRepository->countPastJobs();
$pastTotalPages = max(1, (int) ceil($pastJobsTotal / self::PAST_JOBS_PER_PAGE));
if ($pastPage > $pastTotalPages) {
$pastPage = $pastTotalPages;
}
$pastOffset = ($pastPage - 1) * self::PAST_JOBS_PER_PAGE;
$pastJobs = $this->cronRepository->listPastJobs(self::PAST_JOBS_PER_PAGE, $pastOffset);
} catch (Throwable $exception) {
Flash::set('settings_error', $this->translator->get('settings.cron.flash.load_failed') . ' ' . $exception->getMessage());
$runOnWeb = $this->runOnWebDefault;
@@ -40,6 +50,9 @@ final class CronSettingsController
$schedules = [];
$futureJobs = [];
$pastJobs = [];
$pastJobsTotal = 0;
$pastTotalPages = 1;
$pastPage = 1;
}
$html = $this->template->render('settings/cron', [
@@ -53,6 +66,12 @@ final class CronSettingsController
'schedules' => $schedules,
'futureJobs' => $futureJobs,
'pastJobs' => $pastJobs,
'pastJobsPagination' => [
'page' => $pastPage,
'per_page' => self::PAST_JOBS_PER_PAGE,
'total' => $pastJobsTotal,
'total_pages' => $pastTotalPages,
],
'errorMessage' => (string) Flash::get('settings_error', ''),
'successMessage' => (string) Flash::get('settings_success', ''),
], 'layouts/app');

View File

@@ -42,9 +42,11 @@ final class InpostIntegrationController
public function save(Request $request): Response
{
$redirectTo = $this->resolveRedirectPath((string) $request->input('return_to', '/settings/integrations/inpost'));
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/settings/integrations/inpost');
return Response::redirect($redirectTo);
}
try {
@@ -72,6 +74,19 @@ final class InpostIntegrationController
);
}
return Response::redirect('/settings/integrations/inpost');
return Response::redirect($redirectTo);
}
private function resolveRedirectPath(string $candidate): string
{
$value = trim($candidate);
if ($value === '') {
return '/settings/integrations/inpost';
}
if (!str_starts_with($value, '/settings/integrations')) {
return '/settings/integrations/inpost';
}
return $value;
}
}

View File

@@ -9,10 +9,19 @@ use Throwable;
final class InpostIntegrationRepository
{
private const INTEGRATION_TYPE = 'inpost';
private const INTEGRATION_NAME = 'InPost ShipX';
private const INTEGRATION_BASE_URL = 'https://api-shipx-pl.easypack24.net';
private readonly IntegrationsRepository $integrations;
private readonly IntegrationSecretCipher $cipher;
public function __construct(
private readonly PDO $pdo,
private readonly string $secret
) {
$this->integrations = new IntegrationsRepository($this->pdo);
$this->cipher = new IntegrationSecretCipher($this->secret);
}
/**
@@ -22,16 +31,20 @@ final class InpostIntegrationRepository
{
$row = $this->fetchRow();
if ($row === null) {
return $this->defaultSettings();
$row = $this->defaultSettings();
}
$encryptedToken = $this->resolveEncryptedToken($row);
return [
'has_api_token' => trim((string) ($row['api_token_encrypted'] ?? '')) !== '',
'has_api_token' => $encryptedToken !== null && $encryptedToken !== '',
'organization_id' => (string) ($row['organization_id'] ?? ''),
'environment' => (string) ($row['environment'] ?? 'sandbox'),
'default_dispatch_method' => (string) ($row['default_dispatch_method'] ?? 'pop'),
'default_dispatch_point' => (string) ($row['default_dispatch_point'] ?? ''),
'default_insurance' => $row['default_insurance'] !== null ? (float) $row['default_insurance'] : null,
'default_insurance' => isset($row['default_insurance']) && $row['default_insurance'] !== null
? (float) $row['default_insurance']
: null,
'default_locker_size' => (string) ($row['default_locker_size'] ?? 'small'),
'default_courier_length' => (int) ($row['default_courier_length'] ?? 20),
'default_courier_width' => (int) ($row['default_courier_width'] ?? 15),
@@ -54,12 +67,17 @@ final class InpostIntegrationRepository
throw new RuntimeException('Brak rekordu konfiguracji InPost.');
}
$integrationId = $this->ensureBaseIntegration();
$currentEncrypted = $this->resolveEncryptedToken($current);
$apiToken = trim((string) ($payload['api_token'] ?? ''));
$apiTokenEncrypted = trim((string) ($current['api_token_encrypted'] ?? ''));
$nextEncrypted = $currentEncrypted;
if ($apiToken !== '') {
$apiTokenEncrypted = (string) $this->encrypt($apiToken);
$nextEncrypted = $this->cipher->encrypt($apiToken);
}
$this->integrations->updateApiKeyEncrypted($integrationId, $nextEncrypted);
$statement = $this->pdo->prepare(
'UPDATE inpost_integration_settings
SET api_token_encrypted = :api_token_encrypted,
@@ -80,7 +98,7 @@ final class InpostIntegrationRepository
WHERE id = 1'
);
$statement->execute([
'api_token_encrypted' => $this->nullableString($apiTokenEncrypted),
'api_token_encrypted' => $this->nullableString((string) $nextEncrypted),
'organization_id' => $this->nullableString(trim((string) ($payload['organization_id'] ?? ''))),
'environment' => in_array($payload['environment'] ?? '', ['sandbox', 'production'], true)
? $payload['environment']
@@ -117,12 +135,12 @@ final class InpostIntegrationRepository
return null;
}
$encrypted = trim((string) ($row['api_token_encrypted'] ?? ''));
if ($encrypted === '') {
$encrypted = $this->resolveEncryptedToken($row);
if ($encrypted === null || $encrypted === '') {
return null;
}
return $this->decrypt($encrypted);
return $this->cipher->decrypt($encrypted);
}
private function ensureRow(): void
@@ -174,60 +192,32 @@ final class InpostIntegrationRepository
];
}
private function resolveEncryptedToken(array $row): ?string
{
$integrationId = $this->ensureBaseIntegration();
$fromBase = $this->integrations->getApiKeyEncrypted($integrationId);
if ($fromBase !== null && $fromBase !== '') {
return $fromBase;
}
$legacy = trim((string) ($row['api_token_encrypted'] ?? ''));
return $legacy === '' ? null : $legacy;
}
private function ensureBaseIntegration(): int
{
return $this->integrations->ensureIntegration(
self::INTEGRATION_TYPE,
self::INTEGRATION_NAME,
self::INTEGRATION_BASE_URL,
20,
true
);
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function encrypt(string $plainText): ?string
{
$value = trim($plainText);
if ($value === '') {
return null;
}
if ($this->secret === '') {
throw new RuntimeException('Brak INTEGRATIONS_SECRET do szyfrowania danych integracji.');
}
$encryptionKey = hash('sha256', 'enc|' . $this->secret, true);
$hmacKey = hash('sha256', 'auth|' . $this->secret, true);
$iv = random_bytes(16);
$cipherRaw = openssl_encrypt($value, 'AES-256-CBC', $encryptionKey, OPENSSL_RAW_DATA, $iv);
if ($cipherRaw === false) {
throw new RuntimeException('Nie udalo sie zaszyfrowac danych integracji.');
}
$mac = hash_hmac('sha256', $iv . $cipherRaw, $hmacKey, true);
return 'v1:' . base64_encode($iv . $mac . $cipherRaw);
}
private function decrypt(string $encrypted): ?string
{
if ($this->secret === '') {
throw new RuntimeException('Brak INTEGRATIONS_SECRET do odszyfrowania danych integracji.');
}
if (!str_starts_with($encrypted, 'v1:')) {
return null;
}
$raw = base64_decode(substr($encrypted, 3), true);
if ($raw === false || strlen($raw) < 48) {
return null;
}
$encryptionKey = hash('sha256', 'enc|' . $this->secret, true);
$hmacKey = hash('sha256', 'auth|' . $this->secret, true);
$iv = substr($raw, 0, 16);
$mac = substr($raw, 16, 32);
$cipherRaw = substr($raw, 48);
$expectedMac = hash_hmac('sha256', $iv . $cipherRaw, $hmacKey, true);
if (!hash_equals($expectedMac, $mac)) {
return null;
}
$decrypted = openssl_decrypt($cipherRaw, 'AES-256-CBC', $encryptionKey, OPENSSL_RAW_DATA, $iv);
return $decrypted !== false ? $decrypted : null;
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use RuntimeException;
final class IntegrationSecretCipher
{
public function __construct(private readonly string $secret)
{
}
public function encrypt(string $plainText): ?string
{
$value = trim($plainText);
if ($value === '') {
return null;
}
if ($this->secret === '') {
throw new RuntimeException('Brak INTEGRATIONS_SECRET do szyfrowania danych integracji.');
}
$encryptionKey = hash('sha256', 'enc|' . $this->secret, true);
$hmacKey = hash('sha256', 'auth|' . $this->secret, true);
$iv = random_bytes(16);
$cipherRaw = openssl_encrypt($value, 'AES-256-CBC', $encryptionKey, OPENSSL_RAW_DATA, $iv);
if ($cipherRaw === false) {
throw new RuntimeException('Nie udalo sie zaszyfrowac danych integracji.');
}
$mac = hash_hmac('sha256', $iv . $cipherRaw, $hmacKey, true);
return 'v1:' . base64_encode($iv . $mac . $cipherRaw);
}
public function decrypt(string $encrypted): ?string
{
if ($this->secret === '') {
throw new RuntimeException('Brak INTEGRATIONS_SECRET do odszyfrowania danych integracji.');
}
if (!str_starts_with($encrypted, 'v1:')) {
return null;
}
$raw = base64_decode(substr($encrypted, 3), true);
if ($raw === false || strlen($raw) < 48) {
return null;
}
$encryptionKey = hash('sha256', 'enc|' . $this->secret, true);
$hmacKey = hash('sha256', 'auth|' . $this->secret, true);
$iv = substr($raw, 0, 16);
$mac = substr($raw, 16, 32);
$cipherRaw = substr($raw, 48);
$expectedMac = hash_hmac('sha256', $iv . $cipherRaw, $hmacKey, true);
if (!hash_equals($expectedMac, $mac)) {
return null;
}
$decrypted = openssl_decrypt($cipherRaw, 'AES-256-CBC', $encryptionKey, OPENSSL_RAW_DATA, $iv);
return $decrypted !== false ? $decrypted : null;
}
}

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
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 IntegrationsHubController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly IntegrationsRepository $integrations,
private readonly AllegroIntegrationRepository $allegro,
private readonly ApaczkaIntegrationRepository $apaczka,
private readonly InpostIntegrationRepository $inpost,
private readonly ShopproIntegrationsRepository $shoppro
) {
}
public function index(Request $request): Response
{
$rows = [
$this->buildAllegroRow('sandbox'),
$this->buildAllegroRow('production'),
$this->buildApaczkaRow(),
$this->buildInpostRow(),
$this->buildShopproRow(),
];
$html = $this->template->render('settings/integrations', [
'title' => $this->translator->get('settings.integrations_hub.title'),
'activeMenu' => 'settings',
'activeSettings' => 'integrations',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'rows' => $rows,
'errorMessage' => (string) Flash::get('settings_error', ''),
'successMessage' => (string) Flash::get('settings_success', ''),
], 'layouts/app');
return Response::html($html);
}
/**
* @return array<string, mixed>
*/
private function buildAllegroRow(string $environment): array
{
$env = $environment === 'production' ? 'production' : 'sandbox';
$settings = $this->allegro->getSettings($env);
$integrationName = $env === 'production' ? 'Allegro Production' : 'Allegro Sandbox';
$meta = $this->integrations->findByTypeAndName('allegro', $integrationName) ?? [];
return [
'provider' => $this->translator->get('settings.integrations_hub.providers.allegro'),
'instance' => $this->translator->get('settings.integrations_hub.providers.' . ($env === 'production' ? 'allegro_production' : 'allegro_sandbox')),
'authorization_status' => !empty($settings['is_connected'])
? $this->translator->get('settings.integrations_hub.status.connected')
: $this->translator->get('settings.integrations_hub.status.not_connected'),
'secret_status' => !empty($settings['has_client_secret'])
? $this->translator->get('settings.integrations_hub.status.saved')
: $this->translator->get('settings.integrations_hub.status.missing'),
'is_active' => (int) ($meta['is_active'] ?? 0) === 1,
'last_test_at' => trim((string) ($meta['last_test_at'] ?? '')),
'configure_url' => '/settings/integrations/allegro?env=' . rawurlencode($env),
'configure_label' => $this->translator->get('settings.integrations_hub.actions.configure'),
];
}
/**
* @return array<string, mixed>
*/
private function buildApaczkaRow(): array
{
$settings = $this->apaczka->getSettings();
$meta = $this->integrations->findByTypeAndName('apaczka', 'Apaczka') ?? [];
return [
'provider' => $this->translator->get('settings.integrations_hub.providers.apaczka'),
'instance' => 'Apaczka',
'authorization_status' => !empty($settings['has_api_key'])
? $this->translator->get('settings.integrations_hub.status.configured')
: $this->translator->get('settings.integrations_hub.status.not_configured'),
'secret_status' => !empty($settings['has_api_key'])
? $this->translator->get('settings.integrations_hub.status.saved')
: $this->translator->get('settings.integrations_hub.status.missing'),
'is_active' => (int) ($meta['is_active'] ?? 0) === 1,
'last_test_at' => trim((string) ($meta['last_test_at'] ?? '')),
'configure_url' => '/settings/integrations/apaczka',
'configure_label' => $this->translator->get('settings.integrations_hub.actions.configure'),
];
}
/**
* @return array<string, mixed>
*/
private function buildInpostRow(): array
{
$settings = $this->inpost->getSettings();
$meta = $this->integrations->findByTypeAndName('inpost', 'InPost ShipX') ?? [];
return [
'provider' => $this->translator->get('settings.integrations_hub.providers.inpost'),
'instance' => 'InPost ShipX',
'authorization_status' => !empty($settings['has_api_token'])
? $this->translator->get('settings.integrations_hub.status.configured')
: $this->translator->get('settings.integrations_hub.status.not_configured'),
'secret_status' => !empty($settings['has_api_token'])
? $this->translator->get('settings.integrations_hub.status.saved')
: $this->translator->get('settings.integrations_hub.status.missing'),
'is_active' => (int) ($meta['is_active'] ?? 0) === 1,
'last_test_at' => trim((string) ($meta['last_test_at'] ?? '')),
'configure_url' => '/settings/integrations/inpost',
'configure_label' => $this->translator->get('settings.integrations_hub.actions.configure'),
];
}
/**
* @return array<string, mixed>
*/
private function buildShopproRow(): array
{
$rows = $this->shoppro->listIntegrations();
$instancesCount = count($rows);
$activeCount = 0;
$configuredCount = 0;
$lastTestAt = '';
foreach ($rows as $row) {
if (!empty($row['is_active'])) {
$activeCount++;
}
if (!empty($row['has_api_key'])) {
$configuredCount++;
}
$testedAt = trim((string) ($row['last_test_at'] ?? ''));
if ($testedAt !== '' && ($lastTestAt === '' || strcmp($testedAt, $lastTestAt) > 0)) {
$lastTestAt = $testedAt;
}
}
return [
'provider' => $this->translator->get('settings.integrations_hub.providers.shoppro'),
'instance' => $this->translator->get('settings.integrations_hub.providers.shoppro_instances', [
'count' => $instancesCount,
]),
'authorization_status' => $configuredCount > 0
? $this->translator->get('settings.integrations_hub.status.configured')
: $this->translator->get('settings.integrations_hub.status.not_configured'),
'secret_status' => $configuredCount > 0
? $this->translator->get('settings.integrations_hub.status.saved')
: $this->translator->get('settings.integrations_hub.status.missing'),
'is_active' => $activeCount > 0,
'last_test_at' => $lastTestAt,
'configure_url' => '/settings/integrations/shoppro',
'configure_label' => $this->translator->get('settings.integrations_hub.actions.configure'),
];
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
use Throwable;
final class IntegrationsRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return array<string, mixed>|null
*/
public function findByTypeAndName(string $type, string $name): ?array
{
try {
$statement = $this->pdo->prepare(
'SELECT * FROM integrations WHERE type = :type AND name = :name LIMIT 1'
);
$statement->execute([
'type' => trim($type),
'name' => trim($name),
]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
} catch (Throwable) {
return null;
}
return is_array($row) ? $row : null;
}
/**
* @return array<string, mixed>|null
*/
public function findFirstByType(string $type): ?array
{
try {
$statement = $this->pdo->prepare(
'SELECT * FROM integrations WHERE type = :type ORDER BY id ASC LIMIT 1'
);
$statement->execute(['type' => trim($type)]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
} catch (Throwable) {
return null;
}
return is_array($row) ? $row : null;
}
/**
* @return array<string, mixed>|null
*/
public function findById(int $id): ?array
{
if ($id <= 0) {
return null;
}
try {
$statement = $this->pdo->prepare('SELECT * FROM integrations WHERE id = :id LIMIT 1');
$statement->execute(['id' => $id]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
} catch (Throwable) {
return null;
}
return is_array($row) ? $row : null;
}
public function ensureIntegration(
string $type,
string $name,
string $baseUrl,
int $timeoutSeconds = 10,
bool $isActive = true
): int {
$typeValue = trim($type);
$nameValue = trim($name);
$baseUrlValue = trim($baseUrl);
$existing = $this->findByTypeAndName($typeValue, $nameValue);
if ($existing !== null) {
return (int) ($existing['id'] ?? 0);
}
$statement = $this->pdo->prepare(
'INSERT INTO integrations (
type, name, base_url, timeout_seconds, is_active, created_at, updated_at
) VALUES (
:type, :name, :base_url, :timeout_seconds, :is_active, NOW(), NOW()
)'
);
$statement->execute([
'type' => $typeValue,
'name' => $nameValue,
'base_url' => $baseUrlValue,
'timeout_seconds' => max(1, $timeoutSeconds),
'is_active' => $isActive ? 1 : 0,
]);
return (int) $this->pdo->lastInsertId();
}
public function updateApiKeyEncrypted(int $integrationId, ?string $encrypted): void
{
if ($integrationId <= 0) {
return;
}
$statement = $this->pdo->prepare(
'UPDATE integrations
SET api_key_encrypted = :api_key_encrypted,
updated_at = NOW()
WHERE id = :id'
);
$statement->execute([
'id' => $integrationId,
'api_key_encrypted' => $this->nullableString((string) $encrypted),
]);
}
public function getApiKeyEncrypted(int $integrationId): ?string
{
if ($integrationId <= 0) {
return null;
}
try {
$statement = $this->pdo->prepare(
'SELECT api_key_encrypted FROM integrations WHERE id = :id LIMIT 1'
);
$statement->execute(['id' => $integrationId]);
$value = $statement->fetchColumn();
} catch (Throwable) {
return null;
}
if (!is_string($value)) {
return null;
}
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
}

View File

@@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
final class ShopproApiClient
{
/**
* @return array{ok:bool,http_code:int|null,message:string,items:array<int,array<string,mixed>>,total:int,page:int,per_page:int}
*/
public function fetchOrders(
string $baseUrl,
string $apiKey,
int $timeoutSeconds,
int $page = 1,
int $perPage = 100,
?string $fromDate = null
): array {
$query = [
'endpoint' => 'orders',
'action' => 'list',
'page' => max(1, $page),
'per_page' => max(1, min(100, $perPage)),
'sort' => 'updated_at',
'sort_dir' => 'ASC',
];
$dateFrom = trim((string) $fromDate);
if ($dateFrom !== '') {
$query['date_from'] = $dateFrom;
$query['updated_from'] = $dateFrom;
}
$url = rtrim(trim($baseUrl), '/') . '/api.php?' . http_build_query($query);
$response = $this->requestJson($url, $apiKey, $timeoutSeconds);
if (($response['ok'] ?? false) !== true) {
return [
'ok' => false,
'http_code' => $response['http_code'] ?? null,
'message' => (string) ($response['message'] ?? 'Nie mozna pobrac listy zamowien z shopPRO.'),
'items' => [],
'total' => 0,
'page' => max(1, $page),
'per_page' => max(1, min(100, $perPage)),
];
}
$data = is_array($response['data'] ?? null) ? $response['data'] : [];
$items = [];
if (isset($data['items']) && is_array($data['items'])) {
$items = $data['items'];
} elseif (isset($data['orders']) && is_array($data['orders'])) {
$items = $data['orders'];
} elseif ($data !== [] && array_keys($data) === range(0, count($data) - 1)) {
$items = $data;
}
return [
'ok' => true,
'http_code' => $response['http_code'] ?? null,
'message' => '',
'items' => array_values(array_filter($items, static fn (mixed $row): bool => is_array($row))),
'total' => (int) ($data['total'] ?? count($items)),
'page' => (int) ($data['page'] ?? max(1, $page)),
'per_page' => (int) ($data['per_page'] ?? max(1, min(100, $perPage))),
];
}
/**
* @return array{ok:bool,http_code:int|null,message:string,order:array<string,mixed>|null}
*/
public function fetchOrderById(string $baseUrl, string $apiKey, int $timeoutSeconds, string $orderId): array
{
$id = trim($orderId);
if ($id === '') {
return [
'ok' => false,
'http_code' => null,
'message' => 'Niepoprawne ID zamowienia.',
'order' => null,
];
}
$base = rtrim(trim($baseUrl), '/');
$attemptUrls = [
$base . '/api.php?' . http_build_query(['endpoint' => 'orders', 'action' => 'get', 'id' => $id]),
$base . '/api.php?' . http_build_query(['endpoint' => 'orders', 'action' => 'details', 'id' => $id]),
];
$lastMessage = 'Nie mozna pobrac szczegolow zamowienia z shopPRO.';
$lastCode = null;
foreach ($attemptUrls as $url) {
$response = $this->requestJson($url, $apiKey, $timeoutSeconds);
if (($response['ok'] ?? false) !== true) {
$lastMessage = trim((string) ($response['message'] ?? $lastMessage));
$lastCode = $response['http_code'] ?? null;
continue;
}
$data = $response['data'] ?? null;
if (!is_array($data)) {
continue;
}
if (isset($data['order']) && is_array($data['order'])) {
return [
'ok' => true,
'http_code' => $response['http_code'] ?? null,
'message' => '',
'order' => $data['order'],
];
}
return [
'ok' => true,
'http_code' => $response['http_code'] ?? null,
'message' => '',
'order' => $data,
];
}
return [
'ok' => false,
'http_code' => $lastCode,
'message' => $lastMessage,
'order' => null,
];
}
/**
* @return array{ok:bool,http_code:int|null,message:string,product:array<string,mixed>|null}
*/
public function fetchProductById(string $baseUrl, string $apiKey, int $timeoutSeconds, int $productId): array
{
if ($productId <= 0) {
return [
'ok' => false,
'http_code' => null,
'message' => 'Niepoprawne ID produktu.',
'product' => null,
];
}
$normalizedBaseUrl = rtrim(trim($baseUrl), '/');
$query = http_build_query([
'endpoint' => 'products',
'action' => 'get',
'id' => $productId,
]);
$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 produktu z shopPRO.'),
'product' => null,
];
}
$data = is_array($response['data'] ?? null) ? $response['data'] : null;
if ($data === null) {
return [
'ok' => false,
'http_code' => $response['http_code'] ?? null,
'message' => 'shopPRO zwrocil pusty payload produktu.',
'product' => null,
];
}
return [
'ok' => true,
'http_code' => $response['http_code'] ?? null,
'message' => '',
'product' => $data,
];
}
/**
* @return array{ok:bool,http_code:int|null,message:string,data:array<string,mixed>|array<int,mixed>|null}
*/
private function requestJson(string $url, string $apiKey, int $timeoutSeconds): array
{
$curl = curl_init($url);
if ($curl === false) {
return [
'ok' => false,
'http_code' => null,
'message' => 'Nie udalo sie zainicjalizowac polaczenia HTTP.',
'data' => null,
];
}
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => max(1, min(120, $timeoutSeconds)),
CURLOPT_CONNECTTIMEOUT => max(1, min(120, $timeoutSeconds)),
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'X-Api-Key: ' . $apiKey,
],
]);
$body = curl_exec($curl);
$httpCode = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
$curlError = trim(curl_error($curl));
if ($body === false) {
return [
'ok' => false,
'http_code' => $httpCode > 0 ? $httpCode : null,
'message' => $curlError !== '' ? $curlError : 'Nieznany blad transportu HTTP.',
'data' => null,
];
}
$decoded = json_decode((string) $body, true);
if (!is_array($decoded)) {
return [
'ok' => false,
'http_code' => $httpCode > 0 ? $httpCode : null,
'message' => 'Odpowiedz API nie jest poprawnym JSON.',
'data' => null,
];
}
$apiStatus = trim((string) ($decoded['status'] ?? ''));
$apiCode = trim((string) ($decoded['code'] ?? ''));
$apiMessage = trim((string) ($decoded['message'] ?? ''));
if ($apiStatus !== '' && mb_strtolower($apiStatus) !== 'ok') {
$message = trim('shopPRO zwrocil blad. ' . $apiCode . ' ' . $apiMessage);
return [
'ok' => false,
'http_code' => $httpCode > 0 ? $httpCode : null,
'message' => $message !== '' ? $message : 'Nieznany blad API shopPRO.',
'data' => null,
];
}
if ($httpCode >= 400) {
return [
'ok' => false,
'http_code' => $httpCode,
'message' => $apiMessage !== '' ? $apiMessage : 'Blad HTTP podczas komunikacji z shopPRO.',
'data' => null,
];
}
$data = $decoded['data'] ?? $decoded;
if (!is_array($data)) {
$data = [];
}
return [
'ok' => true,
'http_code' => $httpCode > 0 ? $httpCode : null,
'message' => '',
'data' => $data,
];
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
final class ShopproDeliveryMethodMappingRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return array<int, array<string, mixed>>
*/
public function listMappings(int $integrationId): array
{
if ($integrationId <= 0) {
return [];
}
$stmt = $this->pdo->prepare(
'SELECT *
FROM shoppro_delivery_method_mappings
WHERE integration_id = :integration_id
ORDER BY order_delivery_method ASC'
);
$stmt->execute(['integration_id' => $integrationId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @param array<int, array<string, string>> $mappings
*/
public function saveMappings(int $integrationId, array $mappings): void
{
if ($integrationId <= 0) {
return;
}
$deleteStmt = $this->pdo->prepare(
'DELETE FROM shoppro_delivery_method_mappings WHERE integration_id = :integration_id'
);
$deleteStmt->execute(['integration_id' => $integrationId]);
if ($mappings === []) {
return;
}
$insertStmt = $this->pdo->prepare(
'INSERT INTO shoppro_delivery_method_mappings (
integration_id, order_delivery_method, carrier, allegro_delivery_method_id,
allegro_credentials_id, allegro_carrier_id, allegro_service_name
) VALUES (
:integration_id, :order_delivery_method, :carrier, :allegro_delivery_method_id,
:allegro_credentials_id, :allegro_carrier_id, :allegro_service_name
)'
);
foreach ($mappings as $mapping) {
$orderMethod = trim((string) ($mapping['order_delivery_method'] ?? ''));
$allegroMethodId = trim((string) ($mapping['allegro_delivery_method_id'] ?? ''));
if ($orderMethod === '' || $allegroMethodId === '') {
continue;
}
$carrier = trim((string) ($mapping['carrier'] ?? 'allegro'));
$insertStmt->execute([
'integration_id' => $integrationId,
'order_delivery_method' => $orderMethod,
'carrier' => $carrier !== '' ? $carrier : 'allegro',
'allegro_delivery_method_id' => $allegroMethodId,
'allegro_credentials_id' => trim((string) ($mapping['allegro_credentials_id'] ?? '')),
'allegro_carrier_id' => trim((string) ($mapping['allegro_carrier_id'] ?? '')),
'allegro_service_name' => trim((string) ($mapping['allegro_service_name'] ?? '')),
]);
}
}
/**
* @return array<int, string>
*/
public function getDistinctOrderDeliveryMethods(int $integrationId): array
{
if ($integrationId <= 0) {
return [];
}
$stmt = $this->pdo->prepare(
"SELECT DISTINCT external_carrier_id
FROM orders
WHERE external_carrier_id IS NOT NULL
AND external_carrier_id <> ''
AND source = 'shoppro'
AND integration_id = :integration_id
ORDER BY external_carrier_id ASC"
);
$stmt->execute(['integration_id' => $integrationId]);
$rows = $stmt->fetchAll(PDO::FETCH_COLUMN);
return is_array($rows) ? $rows : [];
}
}

View File

@@ -0,0 +1,868 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use App\Modules\Cron\CronRepository;
use DateInterval;
use DateTimeImmutable;
use RuntimeException;
use Throwable;
final class ShopproIntegrationsController
{
private const ORDERS_IMPORT_JOB_TYPE = 'shoppro_orders_import';
private const ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS = 300;
private const ORDERS_IMPORT_DEFAULT_PRIORITY = 90;
private const ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS = 3;
private const ORDER_STATUS_SYNC_JOB_TYPE = 'shoppro_order_status_sync';
private const ORDER_STATUS_SYNC_DEFAULT_INTERVAL_SECONDS = 900;
private const ORDER_STATUS_SYNC_DEFAULT_PRIORITY = 100;
private const ORDER_STATUS_SYNC_DEFAULT_MAX_ATTEMPTS = 3;
private const PAYMENT_SYNC_JOB_TYPE = 'shoppro_payment_status_sync';
private const PAYMENT_SYNC_DEFAULT_INTERVAL_SECONDS = 600;
private const PAYMENT_SYNC_DEFAULT_PRIORITY = 105;
private const PAYMENT_SYNC_DEFAULT_MAX_ATTEMPTS = 3;
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly ShopproIntegrationsRepository $repository,
private readonly ShopproStatusMappingRepository $statusMappings,
private readonly OrderStatusRepository $orderStatuses,
private readonly CronRepository $cronRepository,
private readonly ShopproDeliveryMethodMappingRepository $deliveryMappings,
private readonly AllegroIntegrationRepository $allegroIntegrationRepository,
private readonly AllegroOAuthClient $allegroOAuthClient,
private readonly AllegroApiClient $allegroApiClient
) {
}
public function index(Request $request): Response
{
$integrations = $this->repository->listIntegrations();
$forceNewMode = trim((string) $request->input('new', '')) === '1';
$selectedId = max(0, (int) $request->input('id', 0));
$selectedIntegration = $selectedId > 0 ? $this->repository->findIntegration($selectedId) : null;
if (!$forceNewMode && $selectedIntegration === null && $integrations !== []) {
$firstId = (int) ($integrations[0]['id'] ?? 0);
if ($firstId > 0) {
$selectedIntegration = $this->repository->findIntegration($firstId);
}
}
$this->ensureImportScheduleExists();
$this->ensureStatusSyncScheduleExists();
$this->ensurePaymentSyncScheduleExists();
$activeTab = $this->resolveTab((string) $request->input('tab', 'integration'));
$discoveredStatuses = $this->readDiscoveredStatuses();
$statusRows = $selectedIntegration !== null
? $this->buildStatusRows((int) ($selectedIntegration['id'] ?? 0), $discoveredStatuses)
: [];
$deliveryServicesData = $activeTab === 'delivery'
? $this->loadDeliveryServices()
: [[], ''];
$deliveryMappings = $selectedIntegration !== null
? $this->deliveryMappings->listMappings((int) ($selectedIntegration['id'] ?? 0))
: [];
$orderDeliveryMethods = $selectedIntegration !== null
? $this->deliveryMappings->getDistinctOrderDeliveryMethods((int) ($selectedIntegration['id'] ?? 0))
: [];
$html = $this->template->render('settings/shoppro', [
'title' => $this->translator->get('settings.integrations.title'),
'activeMenu' => 'settings',
'activeSettings' => 'shoppro',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'activeTab' => $activeTab,
'rows' => $integrations,
'selectedIntegration' => $selectedIntegration,
'form' => $this->buildFormValues($selectedIntegration),
'ordersImportIntervalMinutes' => $this->currentImportIntervalMinutes(),
'statusSyncIntervalMinutes' => $this->currentStatusSyncIntervalMinutes(),
'paymentSyncIntervalMinutes' => $this->currentPaymentSyncIntervalMinutes(),
'statusRows' => $statusRows,
'orderproStatuses' => $this->orderStatuses->listStatuses(),
'deliveryMappings' => $deliveryMappings,
'orderDeliveryMethods' => $orderDeliveryMethods,
'allegroDeliveryServices' => $deliveryServicesData[0],
'allegroDeliveryServicesError' => $deliveryServicesData[1],
'inpostDeliveryServices' => array_values(array_filter(
$deliveryServicesData[0],
static fn (array $svc): bool => stripos((string) ($svc['carrierId'] ?? ''), 'inpost') !== false
)),
'errorMessage' => (string) Flash::get('settings_error', ''),
'successMessage' => (string) Flash::get('settings_success', ''),
], 'layouts/app');
return Response::html($html);
}
public function save(Request $request): Response
{
$integrationId = max(0, (int) $request->input('integration_id', 0));
$tab = $this->resolveTab((string) $request->input('tab', 'integration'));
$redirectBase = '/settings/integrations/shoppro';
$redirectTo = $this->buildRedirectUrl($integrationId, $tab);
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirectTo);
}
$existing = $integrationId > 0 ? $this->repository->findIntegration($integrationId) : null;
if ($integrationId > 0 && $existing === null) {
Flash::set('settings_error', $this->translator->get('settings.integrations.flash.not_found'));
return Response::redirect($this->buildRedirectUrl(0, $tab));
}
$name = trim((string) $request->input('name', ''));
if (mb_strlen($name) < 2) {
Flash::set('settings_error', $this->translator->get('settings.integrations.validation.name_min'));
return Response::redirect($redirectTo);
}
$baseUrl = rtrim(trim((string) $request->input('base_url', '')), '/');
if (!$this->isValidHttpUrl($baseUrl)) {
Flash::set('settings_error', $this->translator->get('settings.integrations.validation.base_url_invalid'));
return Response::redirect($redirectTo);
}
$apiKey = trim((string) $request->input('api_key', ''));
$hasExistingApiKey = (bool) ($existing['has_api_key'] ?? false);
if ($tab === 'integration' && $apiKey === '' && !$hasExistingApiKey) {
Flash::set('settings_error', $this->translator->get('settings.integrations.validation.api_key_required'));
return Response::redirect($redirectTo);
}
$ordersFetchStartDate = trim((string) $request->input('orders_fetch_start_date', ''));
if ($ordersFetchStartDate !== '' && !$this->isValidYmdDate($ordersFetchStartDate)) {
Flash::set('settings_error', $this->translator->get('settings.integrations.validation.orders_fetch_start_date_invalid'));
return Response::redirect($redirectTo);
}
if ($this->isDuplicateName($integrationId, $name)) {
Flash::set('settings_error', $this->translator->get('settings.integrations.validation.name_taken'));
return Response::redirect($redirectTo);
}
try {
$statusSyncDirectionInput = $request->input('order_status_sync_direction', null);
$statusSyncDirection = $statusSyncDirectionInput !== null
? trim((string) $statusSyncDirectionInput)
: (string) ($existing['order_status_sync_direction'] ?? 'shoppro_to_orderpro');
$paymentSyncStatusCodesInput = $request->input('payment_sync_status_codes', null);
if (is_array($paymentSyncStatusCodesInput)) {
$paymentSyncStatusCodes = $paymentSyncStatusCodesInput;
} elseif ($tab === 'settings') {
$paymentSyncStatusCodes = [];
} else {
$paymentSyncStatusCodes = $existing['payment_sync_status_codes'] ?? [];
}
$savedId = $this->repository->saveIntegration([
'integration_id' => $integrationId,
'name' => $name,
'base_url' => $baseUrl,
'api_key' => $apiKey,
'timeout_seconds' => max(1, min(120, (int) $request->input('timeout_seconds', 10))),
'is_active' => $request->input('is_active', ''),
'orders_fetch_enabled' => $request->input('orders_fetch_enabled', ''),
'orders_fetch_start_date' => $ordersFetchStartDate,
'order_status_sync_direction' => $statusSyncDirection,
'payment_sync_status_codes' => $paymentSyncStatusCodes,
]);
$this->saveImportIntervalIfRequested($request);
$this->saveStatusSyncIntervalIfRequested($request);
$this->savePaymentSyncIntervalIfRequested($request);
$flashKey = $integrationId > 0
? 'settings.integrations.flash.updated'
: 'settings.integrations.flash.created';
Flash::set('settings_success', $this->translator->get($flashKey));
return Response::redirect($this->buildRedirectUrl($savedId, $tab));
} catch (Throwable) {
Flash::set('settings_error', $this->translator->get('settings.integrations.flash.failed'));
return Response::redirect($redirectTo);
}
}
public function test(Request $request): Response
{
$integrationId = max(0, (int) $request->input('integration_id', 0));
$tab = $this->resolveTab((string) $request->input('tab', 'integration'));
$redirectBase = '/settings/integrations/shoppro';
$redirectTo = $this->buildRedirectUrl($integrationId, $tab);
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirectTo);
}
if ($integrationId <= 0) {
Flash::set('settings_error', $this->translator->get('settings.integrations.flash.not_found'));
return Response::redirect($this->buildRedirectUrl(0, $tab));
}
$result = $this->repository->testConnection($integrationId);
$isOk = (string) ($result['status'] ?? 'error') === 'ok';
$message = trim((string) ($result['message'] ?? ''));
$httpCode = $result['http_code'] ?? null;
if ($isOk) {
Flash::set('settings_success', $this->translator->get('settings.integrations.flash.test_ok'));
} else {
$suffix = $message !== '' ? ' ' . $message : '';
$httpPart = $httpCode !== null ? ' (HTTP ' . (string) $httpCode . ')' : '';
Flash::set(
'settings_error',
$this->translator->get('settings.integrations.flash.test_failed') . $httpPart . $suffix
);
}
return Response::redirect($redirectTo);
}
public function saveStatusMappings(Request $request): Response
{
$integrationId = max(0, (int) $request->input('integration_id', 0));
$redirectTo = $this->buildRedirectUrl($integrationId, 'statuses');
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirectTo);
}
if ($integrationId <= 0 || $this->repository->findIntegration($integrationId) === null) {
Flash::set('settings_error', $this->translator->get('settings.integrations.flash.not_found'));
return Response::redirect($this->buildRedirectUrl(0, 'statuses'));
}
$shopCodes = $request->input('shoppro_status_code', []);
$shopNames = $request->input('shoppro_status_name', []);
$orderCodes = $request->input('orderpro_status_code', []);
if (!is_array($shopCodes) || !is_array($shopNames) || !is_array($orderCodes)) {
Flash::set('settings_error', $this->translator->get('settings.integrations.statuses.flash.invalid_payload'));
return Response::redirect($redirectTo);
}
$allowedOrderpro = $this->resolveAllowedOrderproStatusCodes();
$rowsCount = min(count($shopCodes), count($shopNames), count($orderCodes));
$mappings = [];
for ($index = 0; $index < $rowsCount; $index++) {
$shopCode = trim((string) ($shopCodes[$index] ?? ''));
$shopName = trim((string) ($shopNames[$index] ?? ''));
$orderCode = strtolower(trim((string) ($orderCodes[$index] ?? '')));
if ($shopCode === '') {
continue;
}
if ($orderCode === '') {
continue;
}
if (!isset($allowedOrderpro[$orderCode])) {
continue;
}
$mappings[] = [
'shoppro_status_code' => $shopCode,
'shoppro_status_name' => $shopName,
'orderpro_status_code' => $orderCode,
];
}
try {
$this->statusMappings->replaceForIntegration($integrationId, $mappings);
Flash::set('settings_success', $this->translator->get('settings.integrations.statuses.flash.saved'));
} catch (Throwable $exception) {
Flash::set(
'settings_error',
$this->translator->get('settings.integrations.statuses.flash.save_failed') . ' ' . $exception->getMessage()
);
}
return Response::redirect($redirectTo);
}
public function syncStatuses(Request $request): Response
{
$integrationId = max(0, (int) $request->input('integration_id', 0));
$redirectTo = $this->buildRedirectUrl($integrationId, 'statuses');
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirectTo);
}
if ($integrationId <= 0 || $this->repository->findIntegration($integrationId) === null) {
Flash::set('settings_error', $this->translator->get('settings.integrations.flash.not_found'));
return Response::redirect($this->buildRedirectUrl(0, 'statuses'));
}
$result = $this->repository->fetchOrderStatuses($integrationId);
if (($result['ok'] ?? false) !== true) {
$message = trim((string) ($result['message'] ?? ''));
Flash::set(
'settings_error',
$this->translator->get('settings.integrations.statuses.flash.sync_failed') . ($message !== '' ? ' ' . $message : '')
);
return Response::redirect($redirectTo);
}
$statuses = $result['statuses'] ?? [];
Flash::set('shoppro_discovered_statuses', is_array($statuses) ? $statuses : []);
Flash::set(
'settings_success',
$this->translator->get('settings.integrations.statuses.flash.sync_ok', [
'count' => (string) (is_array($statuses) ? count($statuses) : 0),
])
);
return Response::redirect($redirectTo);
}
public function saveDeliveryMappings(Request $request): Response
{
$integrationId = max(0, (int) $request->input('integration_id', 0));
$redirectTo = $this->buildRedirectUrl($integrationId, 'delivery');
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirectTo);
}
if ($integrationId <= 0 || $this->repository->findIntegration($integrationId) === null) {
Flash::set('settings_error', $this->translator->get('settings.integrations.flash.not_found'));
return Response::redirect($this->buildRedirectUrl(0, 'delivery'));
}
$orderMethods = (array) $request->input('order_delivery_method', []);
$carriers = (array) $request->input('carrier', []);
$allegroMethodIds = (array) $request->input('allegro_delivery_method_id', []);
$credentialsIds = (array) $request->input('allegro_credentials_id', []);
$carrierIds = (array) $request->input('allegro_carrier_id', []);
$serviceNames = (array) $request->input('allegro_service_name', []);
$mappings = [];
foreach ($orderMethods as $index => $orderMethod) {
$orderMethodValue = trim((string) $orderMethod);
$carrier = trim((string) ($carriers[$index] ?? 'allegro'));
$allegroMethodId = trim((string) ($allegroMethodIds[$index] ?? ''));
if ($orderMethodValue === '' || $allegroMethodId === '') {
continue;
}
$mappings[] = [
'order_delivery_method' => $orderMethodValue,
'carrier' => $carrier,
'allegro_delivery_method_id' => $allegroMethodId,
'allegro_credentials_id' => trim((string) ($credentialsIds[$index] ?? '')),
'allegro_carrier_id' => trim((string) ($carrierIds[$index] ?? '')),
'allegro_service_name' => trim((string) ($serviceNames[$index] ?? '')),
];
}
try {
$this->deliveryMappings->saveMappings($integrationId, $mappings);
Flash::set('settings_success', $this->translator->get('settings.integrations.delivery.flash.saved'));
} catch (Throwable $exception) {
Flash::set(
'settings_error',
$this->translator->get('settings.integrations.delivery.flash.save_failed') . ' ' . $exception->getMessage()
);
}
return Response::redirect($redirectTo);
}
/**
* @param array<string, mixed>|null $integration
* @return array<string, mixed>
*/
private function buildFormValues(?array $integration): array
{
if ($integration === null) {
return [
'integration_id' => 0,
'name' => '',
'base_url' => '',
'timeout_seconds' => 10,
'is_active' => 1,
'orders_fetch_enabled' => 0,
'orders_fetch_start_date' => '',
'order_status_sync_direction' => 'shoppro_to_orderpro',
'payment_sync_status_codes' => [],
];
}
return [
'integration_id' => (int) ($integration['id'] ?? 0),
'name' => (string) ($integration['name'] ?? ''),
'base_url' => (string) ($integration['base_url'] ?? ''),
'timeout_seconds' => (int) ($integration['timeout_seconds'] ?? 10),
'is_active' => !empty($integration['is_active']) ? 1 : 0,
'orders_fetch_enabled' => !empty($integration['orders_fetch_enabled']) ? 1 : 0,
'orders_fetch_start_date' => (string) ($integration['orders_fetch_start_date'] ?? ''),
'order_status_sync_direction' => (string) ($integration['order_status_sync_direction'] ?? 'shoppro_to_orderpro'),
'payment_sync_status_codes' => is_array($integration['payment_sync_status_codes'] ?? null)
? $integration['payment_sync_status_codes']
: [],
];
}
private function isDuplicateName(int $currentId, string $name): bool
{
$needle = mb_strtolower(trim($name));
if ($needle === '') {
return false;
}
$rows = $this->repository->listIntegrations();
foreach ($rows as $row) {
$rowId = (int) ($row['id'] ?? 0);
if ($rowId === $currentId) {
continue;
}
$rowName = mb_strtolower(trim((string) ($row['name'] ?? '')));
if ($rowName === $needle) {
return true;
}
}
return false;
}
private function isValidHttpUrl(string $value): bool
{
if (filter_var($value, FILTER_VALIDATE_URL) === false) {
return false;
}
$scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME));
return in_array($scheme, ['http', 'https'], true);
}
private function isValidYmdDate(string $value): bool
{
$date = DateTimeImmutable::createFromFormat('Y-m-d', $value);
return $date !== false && $date->format('Y-m-d') === $value;
}
/**
* @return array<string, array{shoppro_status_code:string,shoppro_status_name:string,orderpro_status_code:string}>
*/
private function buildStatusRows(int $integrationId, array $discoveredStatuses): array
{
$mappedRows = $this->statusMappings->listByIntegration($integrationId);
$result = [];
foreach ($mappedRows as $row) {
$code = trim((string) ($row['shoppro_status_code'] ?? ''));
if ($code === '') {
continue;
}
$key = mb_strtolower($code);
$result[$key] = [
'shoppro_status_code' => $code,
'shoppro_status_name' => trim((string) ($row['shoppro_status_name'] ?? '')),
'orderpro_status_code' => strtolower(trim((string) ($row['orderpro_status_code'] ?? ''))),
];
}
foreach ($discoveredStatuses as $status) {
if (!is_array($status)) {
continue;
}
$code = trim((string) ($status['code'] ?? ''));
if ($code === '') {
continue;
}
$key = mb_strtolower($code);
if (!isset($result[$key])) {
$result[$key] = [
'shoppro_status_code' => $code,
'shoppro_status_name' => trim((string) ($status['name'] ?? '')),
'orderpro_status_code' => '',
];
}
}
uasort($result, static function (array $left, array $right): int {
return strcmp(
strtolower((string) ($left['shoppro_status_code'] ?? '')),
strtolower((string) ($right['shoppro_status_code'] ?? ''))
);
});
return array_values($result);
}
/**
* @return array<string, true>
*/
private function resolveAllowedOrderproStatusCodes(): array
{
$allowed = [];
foreach ($this->orderStatuses->listStatuses() as $status) {
if (!is_array($status)) {
continue;
}
$code = strtolower(trim((string) ($status['code'] ?? '')));
if ($code === '') {
continue;
}
$allowed[$code] = true;
}
return $allowed;
}
/**
* @return array<int, array{code:string,name:string}>
*/
private function readDiscoveredStatuses(): array
{
$raw = Flash::get('shoppro_discovered_statuses', []);
if (!is_array($raw)) {
return [];
}
$result = [];
foreach ($raw as $item) {
if (!is_array($item)) {
continue;
}
$code = trim((string) ($item['code'] ?? ''));
if ($code === '') {
continue;
}
$result[] = [
'code' => $code,
'name' => trim((string) ($item['name'] ?? $code)),
];
}
return $result;
}
private function resolveTab(string $candidate): string
{
$value = trim($candidate);
$allowed = ['integration', 'statuses', 'settings', 'delivery'];
if (!in_array($value, $allowed, true)) {
return 'integration';
}
return $value;
}
private function buildRedirectUrl(int $integrationId, string $tab): string
{
$url = '/settings/integrations/shoppro';
$query = [];
if ($integrationId > 0) {
$query['id'] = (string) $integrationId;
}
if ($tab !== 'integration') {
$query['tab'] = $tab;
}
if ($query === []) {
return $url;
}
return $url . '?' . http_build_query($query);
}
private function currentImportIntervalMinutes(): int
{
$schedule = $this->findImportSchedule();
$seconds = (int) ($schedule['interval_seconds'] ?? self::ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS);
return max(1, min(1440, (int) floor(max(60, $seconds) / 60)));
}
/**
* @return array<string, mixed>
*/
private function findImportSchedule(): array
{
foreach ($this->cronRepository->listSchedules() as $schedule) {
if ((string) ($schedule['job_type'] ?? '') !== self::ORDERS_IMPORT_JOB_TYPE) {
continue;
}
return $schedule;
}
return [];
}
/**
* @return array<string, mixed>
*/
private function findStatusSyncSchedule(): array
{
foreach ($this->cronRepository->listSchedules() as $schedule) {
if ((string) ($schedule['job_type'] ?? '') !== self::ORDER_STATUS_SYNC_JOB_TYPE) {
continue;
}
return $schedule;
}
return [];
}
/**
* @return array<string, mixed>
*/
private function findPaymentSyncSchedule(): array
{
foreach ($this->cronRepository->listSchedules() as $schedule) {
if ((string) ($schedule['job_type'] ?? '') !== self::PAYMENT_SYNC_JOB_TYPE) {
continue;
}
return $schedule;
}
return [];
}
private function ensureImportScheduleExists(): void
{
try {
if ($this->findImportSchedule() !== []) {
return;
}
$this->cronRepository->upsertSchedule(
self::ORDERS_IMPORT_JOB_TYPE,
self::ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS,
self::ORDERS_IMPORT_DEFAULT_PRIORITY,
self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS,
null,
true
);
} catch (Throwable) {
return;
}
}
private function ensureStatusSyncScheduleExists(): void
{
try {
if ($this->findStatusSyncSchedule() !== []) {
return;
}
$this->cronRepository->upsertSchedule(
self::ORDER_STATUS_SYNC_JOB_TYPE,
self::ORDER_STATUS_SYNC_DEFAULT_INTERVAL_SECONDS,
self::ORDER_STATUS_SYNC_DEFAULT_PRIORITY,
self::ORDER_STATUS_SYNC_DEFAULT_MAX_ATTEMPTS,
null,
true
);
} catch (Throwable) {
return;
}
}
private function ensurePaymentSyncScheduleExists(): void
{
try {
if ($this->findPaymentSyncSchedule() !== []) {
return;
}
$this->cronRepository->upsertSchedule(
self::PAYMENT_SYNC_JOB_TYPE,
self::PAYMENT_SYNC_DEFAULT_INTERVAL_SECONDS,
self::PAYMENT_SYNC_DEFAULT_PRIORITY,
self::PAYMENT_SYNC_DEFAULT_MAX_ATTEMPTS,
null,
true
);
} catch (Throwable) {
return;
}
}
private function saveImportIntervalIfRequested(Request $request): void
{
if ($request->input('orders_import_interval_minutes', null) === null) {
return;
}
$this->ensureImportScheduleExists();
$minutes = max(1, min(1440, (int) $request->input('orders_import_interval_minutes', 5)));
$schedule = $this->findImportSchedule();
$priority = max(1, min(255, (int) ($schedule['priority'] ?? self::ORDERS_IMPORT_DEFAULT_PRIORITY)));
$maxAttempts = max(1, min(20, (int) ($schedule['max_attempts'] ?? self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS)));
$payload = is_array($schedule['payload'] ?? null) ? $schedule['payload'] : null;
$enabled = array_key_exists('enabled', $schedule) ? !empty($schedule['enabled']) : true;
$this->cronRepository->upsertSchedule(
self::ORDERS_IMPORT_JOB_TYPE,
$minutes * 60,
$priority,
$maxAttempts,
$payload,
$enabled
);
}
private function currentStatusSyncIntervalMinutes(): int
{
$schedule = $this->findStatusSyncSchedule();
$seconds = (int) ($schedule['interval_seconds'] ?? self::ORDER_STATUS_SYNC_DEFAULT_INTERVAL_SECONDS);
return max(1, min(1440, (int) floor(max(60, $seconds) / 60)));
}
private function saveStatusSyncIntervalIfRequested(Request $request): void
{
if ($request->input('status_sync_interval_minutes', null) === null) {
return;
}
$this->ensureStatusSyncScheduleExists();
$minutes = max(1, min(1440, (int) $request->input('status_sync_interval_minutes', 15)));
$schedule = $this->findStatusSyncSchedule();
$priority = max(1, min(255, (int) ($schedule['priority'] ?? self::ORDER_STATUS_SYNC_DEFAULT_PRIORITY)));
$maxAttempts = max(1, min(20, (int) ($schedule['max_attempts'] ?? self::ORDER_STATUS_SYNC_DEFAULT_MAX_ATTEMPTS)));
$payload = is_array($schedule['payload'] ?? null) ? $schedule['payload'] : null;
$enabled = array_key_exists('enabled', $schedule) ? !empty($schedule['enabled']) : true;
$this->cronRepository->upsertSchedule(
self::ORDER_STATUS_SYNC_JOB_TYPE,
$minutes * 60,
$priority,
$maxAttempts,
$payload,
$enabled
);
}
private function currentPaymentSyncIntervalMinutes(): int
{
$schedule = $this->findPaymentSyncSchedule();
$seconds = (int) ($schedule['interval_seconds'] ?? self::PAYMENT_SYNC_DEFAULT_INTERVAL_SECONDS);
return max(1, min(1440, (int) floor(max(60, $seconds) / 60)));
}
private function savePaymentSyncIntervalIfRequested(Request $request): void
{
if ($request->input('payment_sync_interval_minutes', null) === null) {
return;
}
$this->ensurePaymentSyncScheduleExists();
$minutes = max(1, min(1440, (int) $request->input('payment_sync_interval_minutes', 10)));
$schedule = $this->findPaymentSyncSchedule();
$priority = max(1, min(255, (int) ($schedule['priority'] ?? self::PAYMENT_SYNC_DEFAULT_PRIORITY)));
$maxAttempts = max(1, min(20, (int) ($schedule['max_attempts'] ?? self::PAYMENT_SYNC_DEFAULT_MAX_ATTEMPTS)));
$payload = is_array($schedule['payload'] ?? null) ? $schedule['payload'] : null;
$enabled = array_key_exists('enabled', $schedule) ? !empty($schedule['enabled']) : true;
$this->cronRepository->upsertSchedule(
self::PAYMENT_SYNC_JOB_TYPE,
$minutes * 60,
$priority,
$maxAttempts,
$payload,
$enabled
);
}
/**
* @return array{0: array<int, array<string, mixed>>, 1: string}
*/
private function loadDeliveryServices(): array
{
try {
$oauth = $this->allegroIntegrationRepository->getTokenCredentials();
if (!is_array($oauth)) {
return [[], $this->translator->get('settings.integrations.delivery.not_connected')];
}
$env = (string) ($oauth['environment'] ?? 'sandbox');
$accessToken = trim((string) ($oauth['access_token'] ?? ''));
if ($accessToken === '') {
return [[], $this->translator->get('settings.integrations.delivery.not_connected')];
}
try {
$response = $this->allegroApiClient->getDeliveryServices($env, $accessToken);
} catch (RuntimeException $exception) {
if (trim($exception->getMessage()) !== 'ALLEGRO_HTTP_401') {
throw $exception;
}
$refreshedToken = $this->refreshAllegroAccessToken($oauth);
if ($refreshedToken === null) {
return [[], $this->translator->get('settings.integrations.delivery.not_connected')];
}
$response = $this->allegroApiClient->getDeliveryServices($env, $refreshedToken);
}
$services = is_array($response['services'] ?? null) ? $response['services'] : [];
return [$services, ''];
} catch (Throwable $exception) {
return [[], $exception->getMessage()];
}
}
/**
* @param array<string, mixed> $oauth
*/
private function refreshAllegroAccessToken(array $oauth): ?string
{
try {
$token = $this->allegroOAuthClient->refreshAccessToken(
(string) ($oauth['environment'] ?? 'sandbox'),
(string) ($oauth['client_id'] ?? ''),
(string) ($oauth['client_secret'] ?? ''),
(string) ($oauth['refresh_token'] ?? '')
);
$expiresIn = max(0, (int) ($token['expires_in'] ?? 0));
$expiresAt = $expiresIn > 0
? (new DateTimeImmutable('now'))->add(new DateInterval('PT' . $expiresIn . 'S'))->format('Y-m-d H:i:s')
: null;
$refreshToken = trim((string) ($token['refresh_token'] ?? ''));
if ($refreshToken === '') {
$refreshToken = (string) ($oauth['refresh_token'] ?? '');
}
$this->allegroIntegrationRepository->saveTokens(
(string) ($token['access_token'] ?? ''),
$refreshToken,
(string) ($token['token_type'] ?? ''),
(string) ($token['scope'] ?? ''),
$expiresAt
);
$accessToken = trim((string) ($token['access_token'] ?? ''));
return $accessToken !== '' ? $accessToken : null;
} catch (Throwable) {
return null;
}
}
}

View File

@@ -0,0 +1,591 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
use RuntimeException;
use Throwable;
final class ShopproIntegrationsRepository
{
private const TYPE = 'shoppro';
private const DIRECTION_SHOPPRO_TO_ORDERPRO = 'shoppro_to_orderpro';
private const DIRECTION_ORDERPRO_TO_SHOPPRO = 'orderpro_to_shoppro';
private readonly IntegrationSecretCipher $cipher;
public function __construct(
private readonly PDO $pdo,
private readonly string $secret
) {
$this->cipher = new IntegrationSecretCipher($this->secret);
}
/**
* @return array<int, array<string, mixed>>
*/
public function listIntegrations(): array
{
try {
$statement = $this->pdo->prepare(
'SELECT *
FROM integrations
WHERE type = :type
ORDER BY is_active DESC, name ASC, id ASC'
);
$statement->execute(['type' => self::TYPE]);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable) {
return [];
}
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$encrypted = trim((string) ($row['api_key_encrypted'] ?? ''));
$result[] = [
'id' => (int) ($row['id'] ?? 0),
'name' => trim((string) ($row['name'] ?? '')),
'base_url' => trim((string) ($row['base_url'] ?? '')),
'timeout_seconds' => (int) ($row['timeout_seconds'] ?? 10),
'is_active' => (int) ($row['is_active'] ?? 0) === 1,
'orders_fetch_enabled' => (int) ($row['orders_fetch_enabled'] ?? 0) === 1,
'orders_fetch_start_date' => trim((string) ($row['orders_fetch_start_date'] ?? '')),
'order_status_sync_direction' => $this->normalizeStatusSyncDirection((string) ($row['order_status_sync_direction'] ?? '')),
'payment_sync_status_codes' => $this->decodeStatusCodesJson($row['payment_sync_status_codes_json'] ?? null),
'has_api_key' => $encrypted !== '',
'last_test_status' => trim((string) ($row['last_test_status'] ?? '')),
'last_test_http_code' => $row['last_test_http_code'] !== null ? (int) $row['last_test_http_code'] : null,
'last_test_message' => trim((string) ($row['last_test_message'] ?? '')),
'last_test_at' => trim((string) ($row['last_test_at'] ?? '')),
];
}
return $result;
}
/**
* @return array<string, mixed>|null
*/
public function findIntegration(int $integrationId): ?array
{
if ($integrationId <= 0) {
return null;
}
try {
$statement = $this->pdo->prepare(
'SELECT *
FROM integrations
WHERE id = :id AND type = :type
LIMIT 1'
);
$statement->execute([
'id' => $integrationId,
'type' => self::TYPE,
]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
} catch (Throwable) {
return null;
}
if (!is_array($row)) {
return null;
}
return [
'id' => (int) ($row['id'] ?? 0),
'name' => trim((string) ($row['name'] ?? '')),
'base_url' => trim((string) ($row['base_url'] ?? '')),
'timeout_seconds' => (int) ($row['timeout_seconds'] ?? 10),
'is_active' => (int) ($row['is_active'] ?? 0) === 1,
'orders_fetch_enabled' => (int) ($row['orders_fetch_enabled'] ?? 0) === 1,
'orders_fetch_start_date' => trim((string) ($row['orders_fetch_start_date'] ?? '')),
'order_status_sync_direction' => $this->normalizeStatusSyncDirection((string) ($row['order_status_sync_direction'] ?? '')),
'payment_sync_status_codes' => $this->decodeStatusCodesJson($row['payment_sync_status_codes_json'] ?? null),
'payment_sync_status_codes_json' => $row['payment_sync_status_codes_json'] ?? null,
'api_key_encrypted' => trim((string) ($row['api_key_encrypted'] ?? '')),
'has_api_key' => trim((string) ($row['api_key_encrypted'] ?? '')) !== '',
];
}
public function getApiKeyDecrypted(int $integrationId): ?string
{
$integration = $this->findIntegration($integrationId);
if ($integration === null) {
return null;
}
$encrypted = trim((string) ($integration['api_key_encrypted'] ?? ''));
if ($encrypted === '') {
return null;
}
$decrypted = (string) $this->cipher->decrypt($encrypted);
$trimmed = trim($decrypted);
return $trimmed === '' ? null : $trimmed;
}
/**
* @param array<string, mixed> $payload
*/
public function saveIntegration(array $payload): int
{
$integrationId = (int) ($payload['integration_id'] ?? 0);
$name = trim((string) ($payload['name'] ?? ''));
$baseUrl = rtrim(trim((string) ($payload['base_url'] ?? '')), '/');
$timeoutSeconds = max(1, (int) ($payload['timeout_seconds'] ?? 10));
$isActive = !empty($payload['is_active']) ? 1 : 0;
$ordersFetchEnabled = !empty($payload['orders_fetch_enabled']) ? 1 : 0;
$ordersFetchStartDate = $this->normalizeDate((string) ($payload['orders_fetch_start_date'] ?? ''));
$statusSyncDirection = $this->normalizeStatusSyncDirection((string) ($payload['order_status_sync_direction'] ?? ''));
$paymentSyncStatusCodesJson = $this->encodeStatusCodesJson($payload['payment_sync_status_codes'] ?? []);
$apiKey = trim((string) ($payload['api_key'] ?? ''));
if ($integrationId > 0) {
$existing = $this->findIntegration($integrationId);
if ($existing === null) {
throw new RuntimeException('INTEGRATION_NOT_FOUND');
}
$encryptedApiKey = trim((string) ($existing['api_key_encrypted'] ?? ''));
if ($apiKey !== '') {
$encryptedApiKey = (string) $this->cipher->encrypt($apiKey);
}
$statement = $this->pdo->prepare(
'UPDATE integrations
SET name = :name,
base_url = :base_url,
api_key_encrypted = :api_key_encrypted,
timeout_seconds = :timeout_seconds,
is_active = :is_active,
orders_fetch_enabled = :orders_fetch_enabled,
orders_fetch_start_date = :orders_fetch_start_date,
order_status_sync_direction = :order_status_sync_direction,
payment_sync_status_codes_json = :payment_sync_status_codes_json,
updated_at = NOW()
WHERE id = :id AND type = :type'
);
$statement->execute([
'id' => $integrationId,
'type' => self::TYPE,
'name' => $name,
'base_url' => $baseUrl,
'api_key_encrypted' => $this->nullableString($encryptedApiKey),
'timeout_seconds' => $timeoutSeconds,
'is_active' => $isActive,
'orders_fetch_enabled' => $ordersFetchEnabled,
'orders_fetch_start_date' => $ordersFetchStartDate,
'order_status_sync_direction' => $statusSyncDirection,
'payment_sync_status_codes_json' => $paymentSyncStatusCodesJson,
]);
return $integrationId;
}
$encryptedApiKey = $this->cipher->encrypt($apiKey);
$statement = $this->pdo->prepare(
'INSERT INTO integrations (
type, name, base_url, api_key_encrypted, timeout_seconds, is_active, orders_fetch_enabled, orders_fetch_start_date, order_status_sync_direction, payment_sync_status_codes_json, created_at, updated_at
) VALUES (
:type, :name, :base_url, :api_key_encrypted, :timeout_seconds, :is_active, :orders_fetch_enabled, :orders_fetch_start_date, :order_status_sync_direction, :payment_sync_status_codes_json, NOW(), NOW()
)'
);
$statement->execute([
'type' => self::TYPE,
'name' => $name,
'base_url' => $baseUrl,
'api_key_encrypted' => $this->nullableString((string) $encryptedApiKey),
'timeout_seconds' => $timeoutSeconds,
'is_active' => $isActive,
'orders_fetch_enabled' => $ordersFetchEnabled,
'orders_fetch_start_date' => $ordersFetchStartDate,
'order_status_sync_direction' => $statusSyncDirection,
'payment_sync_status_codes_json' => $paymentSyncStatusCodesJson,
]);
return (int) $this->pdo->lastInsertId();
}
/**
* @return array{status:string,http_code:int|null,message:string}
*/
public function testConnection(int $integrationId): array
{
$integration = $this->findIntegration($integrationId);
if ($integration === null) {
return [
'status' => 'error',
'http_code' => null,
'message' => 'Nie znaleziono integracji shopPRO.',
];
}
$apiKeyEncrypted = trim((string) ($integration['api_key_encrypted'] ?? ''));
$apiKey = $apiKeyEncrypted !== '' ? (string) $this->cipher->decrypt($apiKeyEncrypted) : '';
if ($apiKey === '') {
return [
'status' => 'error',
'http_code' => null,
'message' => 'Brak zapisanego klucza API.',
];
}
$baseUrl = rtrim((string) ($integration['base_url'] ?? ''), '/');
$timeout = max(1, min(120, (int) ($integration['timeout_seconds'] ?? 10)));
$url = $baseUrl . '/api.php?endpoint=dictionaries&action=statuses';
$curl = curl_init($url);
if ($curl === false) {
return [
'status' => 'error',
'http_code' => null,
'message' => 'Nie udalo sie zainicjalizowac polaczenia HTTP.',
];
}
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => $timeout,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'X-Api-Key: ' . $apiKey,
],
]);
$body = curl_exec($curl);
$httpCode = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
$curlError = curl_error($curl);
curl_close($curl);
if ($body === false) {
$message = trim($curlError) !== '' ? trim($curlError) : 'Nieznany blad transportu HTTP.';
$result = [
'status' => 'error',
'http_code' => $httpCode > 0 ? $httpCode : null,
'message' => $message,
];
$this->storeTestResult($integrationId, $url, $result);
return $result;
}
$decoded = json_decode((string) $body, true);
if (!is_array($decoded)) {
$result = [
'status' => 'error',
'http_code' => $httpCode > 0 ? $httpCode : null,
'message' => 'Odpowiedz nie jest poprawnym JSON.',
];
$this->storeTestResult($integrationId, $url, $result);
return $result;
}
$apiStatus = trim((string) ($decoded['status'] ?? ''));
$isOk = $httpCode >= 200 && $httpCode < 300 && $apiStatus === 'ok';
$message = $isOk
? 'Polaczenie z shopPRO dziala poprawnie.'
: trim((string) ($decoded['message'] ?? 'Blad odpowiedzi API shopPRO.'));
$result = [
'status' => $isOk ? 'ok' : 'error',
'http_code' => $httpCode > 0 ? $httpCode : null,
'message' => $message,
];
$this->storeTestResult($integrationId, $url, $result);
return $result;
}
/**
* @return array{ok:bool,http_code:int|null,message:string,statuses:array<int,array{code:string,name:string}>}
*/
public function fetchOrderStatuses(int $integrationId): array
{
$integration = $this->findIntegration($integrationId);
if ($integration === null) {
return [
'ok' => false,
'http_code' => null,
'message' => 'Nie znaleziono integracji shopPRO.',
'statuses' => [],
];
}
$apiKeyEncrypted = trim((string) ($integration['api_key_encrypted'] ?? ''));
$apiKey = $apiKeyEncrypted !== '' ? (string) $this->cipher->decrypt($apiKeyEncrypted) : '';
if ($apiKey === '') {
return [
'ok' => false,
'http_code' => null,
'message' => 'Brak zapisanego klucza API.',
'statuses' => [],
];
}
$baseUrl = rtrim((string) ($integration['base_url'] ?? ''), '/');
$timeout = max(1, min(120, (int) ($integration['timeout_seconds'] ?? 10)));
$url = $baseUrl . '/api.php?endpoint=dictionaries&action=statuses';
$curl = curl_init($url);
if ($curl === false) {
return [
'ok' => false,
'http_code' => null,
'message' => 'Nie udalo sie zainicjalizowac polaczenia HTTP.',
'statuses' => [],
];
}
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => $timeout,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'X-Api-Key: ' . $apiKey,
],
]);
$body = curl_exec($curl);
$httpCode = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
$curlError = curl_error($curl);
curl_close($curl);
if ($body === false) {
return [
'ok' => false,
'http_code' => $httpCode > 0 ? $httpCode : null,
'message' => trim($curlError) !== '' ? trim($curlError) : 'Nieznany blad transportu HTTP.',
'statuses' => [],
];
}
$decoded = json_decode((string) $body, true);
if (!is_array($decoded)) {
return [
'ok' => false,
'http_code' => $httpCode > 0 ? $httpCode : null,
'message' => 'Odpowiedz nie jest poprawnym JSON.',
'statuses' => [],
];
}
$payload = isset($decoded['data']) && is_array($decoded['data'])
? $decoded['data']
: $decoded;
$rawStatuses = [];
if (isset($payload['statuses']) && is_array($payload['statuses'])) {
$rawStatuses = $payload['statuses'];
} elseif (isset($payload['order_statuses']) && is_array($payload['order_statuses'])) {
$rawStatuses = $payload['order_statuses'];
} elseif ($payload !== [] && array_keys($payload) === range(0, count($payload) - 1)) {
$rawStatuses = $payload;
}
$statuses = $this->normalizeStatusesPayload($rawStatuses);
return [
'ok' => true,
'http_code' => $httpCode > 0 ? $httpCode : null,
'message' => '',
'statuses' => $statuses,
];
}
/**
* @param array{status:string,http_code:int|null,message:string} $result
*/
private function storeTestResult(int $integrationId, string $endpointUrl, array $result): void
{
$status = trim((string) ($result['status'] ?? 'error'));
$httpCode = $result['http_code'] ?? null;
$message = mb_substr(trim((string) ($result['message'] ?? '')), 0, 255);
try {
$statement = $this->pdo->prepare(
'UPDATE integrations
SET last_test_status = :last_test_status,
last_test_http_code = :last_test_http_code,
last_test_message = :last_test_message,
last_test_at = NOW(),
updated_at = NOW()
WHERE id = :id AND type = :type'
);
$statement->execute([
'id' => $integrationId,
'type' => self::TYPE,
'last_test_status' => $this->nullableString($status),
'last_test_http_code' => $httpCode,
'last_test_message' => $this->nullableString($message),
]);
$log = $this->pdo->prepare(
'INSERT INTO integration_test_logs (
integration_id, status, http_code, message, endpoint_url, tested_at
) VALUES (
:integration_id, :status, :http_code, :message, :endpoint_url, NOW()
)'
);
$log->execute([
'integration_id' => $integrationId,
'status' => $status,
'http_code' => $httpCode,
'message' => $message,
'endpoint_url' => $endpointUrl,
]);
} catch (Throwable) {
return;
}
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function normalizeDate(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function normalizeStatusSyncDirection(string $value): string
{
$normalized = trim(mb_strtolower($value));
if ($normalized === self::DIRECTION_ORDERPRO_TO_SHOPPRO) {
return self::DIRECTION_ORDERPRO_TO_SHOPPRO;
}
return self::DIRECTION_SHOPPRO_TO_ORDERPRO;
}
/**
* @param mixed $value
* @return array<int, string>
*/
private function decodeStatusCodesJson(mixed $value): array
{
if (!is_string($value) || trim($value) === '') {
return [];
}
$decoded = json_decode($value, true);
if (!is_array($decoded)) {
return [];
}
$result = [];
$seen = [];
foreach ($decoded as $rawCode) {
$code = strtolower(trim((string) $rawCode));
if ($code === '' || isset($seen[$code])) {
continue;
}
$seen[$code] = true;
$result[] = $code;
}
return $result;
}
private function encodeStatusCodesJson(mixed $rawCodes): ?string
{
if (!is_array($rawCodes)) {
return null;
}
$codes = [];
$seen = [];
foreach ($rawCodes as $rawCode) {
$code = strtolower(trim((string) $rawCode));
if ($code === '' || isset($seen[$code])) {
continue;
}
$seen[$code] = true;
$codes[] = $code;
}
if ($codes === []) {
return null;
}
return json_encode($codes, JSON_UNESCAPED_UNICODE);
}
/**
* @param array<int, mixed> $rawStatuses
* @return array<int, array{code:string,name:string}>
*/
private function normalizeStatusesPayload(array $rawStatuses): array
{
$result = [];
$seen = [];
foreach ($rawStatuses as $key => $item) {
if (!is_array($item)) {
$codeFromKey = trim((string) $key);
$nameFromValue = trim((string) $item);
if ($codeFromKey !== '') {
$normalizedCode = mb_strtolower($codeFromKey);
if (!isset($seen[$normalizedCode])) {
$seen[$normalizedCode] = true;
$result[] = [
'code' => $codeFromKey,
'name' => $nameFromValue !== '' ? $nameFromValue : $codeFromKey,
];
}
}
continue;
}
if (!is_array($item)) {
continue;
}
$code = trim((string) (
$item['code']
?? $item['status_code']
?? $item['status']
?? $item['symbol']
?? $item['slug']
?? $item['id']
?? $key
));
$name = trim((string) (
$item['name']
?? $item['status_name']
?? $item['label']
?? $item['title']
?? $item['value']
?? $code
));
if ($code === '') {
continue;
}
$normalizedCode = mb_strtolower($code);
if (isset($seen[$normalizedCode])) {
continue;
}
$seen[$normalizedCode] = true;
$result[] = [
'code' => $code,
'name' => $name !== '' ? $name : $code,
];
}
return $result;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,404 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Modules\Orders\OrdersRepository;
use DateTimeImmutable;
use PDO;
use Throwable;
final class ShopproPaymentStatusSyncService
{
private const PAID_STATUS = 2;
private const UNPAID_STATUS = 0;
/**
* @var array<int, string>
*/
private const DEFAULT_FINAL_STATUS_CODES = [
'wyslane',
'zrealizowane',
'anulowane',
'cancelled',
'canceled',
'delivered',
'returned',
'shipped',
];
public function __construct(
private readonly ShopproIntegrationsRepository $integrations,
private readonly ShopproApiClient $apiClient,
private readonly OrdersRepository $orders,
private readonly PDO $pdo
) {
}
/**
* @return array<string, mixed>
*/
public function sync(array $options = []): array
{
$perIntegrationLimit = max(1, min(500, (int) ($options['per_integration_limit'] ?? 100)));
$result = [
'ok' => true,
'checked_integrations' => 0,
'processed_orders' => 0,
'updated_orders' => 0,
'skipped_orders' => 0,
'failed_orders' => 0,
'errors' => [],
];
foreach ($this->integrations->listIntegrations() as $integration) {
$integrationId = (int) ($integration['id'] ?? 0);
if ($integrationId <= 0 || empty($integration['is_active']) || empty($integration['has_api_key'])) {
continue;
}
$baseUrl = trim((string) ($integration['base_url'] ?? ''));
$apiKey = $this->integrations->getApiKeyDecrypted($integrationId);
$timeout = max(1, min(120, (int) ($integration['timeout_seconds'] ?? 10)));
if ($baseUrl === '' || $apiKey === null || trim($apiKey) === '') {
continue;
}
$result['checked_integrations'] = (int) $result['checked_integrations'] + 1;
$watchedStatuses = $this->resolveWatchedStatusCodes($integration);
$orders = $this->findCandidateOrders($integrationId, $watchedStatuses, $perIntegrationLimit);
foreach ($orders as $order) {
$result['processed_orders'] = (int) $result['processed_orders'] + 1;
$sourceOrderId = trim((string) ($order['source_order_id'] ?? ''));
if ($sourceOrderId === '') {
$result['skipped_orders'] = (int) $result['skipped_orders'] + 1;
continue;
}
try {
$updated = $this->syncSingleOrderPayment(
$integrationId,
$baseUrl,
$apiKey,
$timeout,
$order
);
if ($updated) {
$result['updated_orders'] = (int) $result['updated_orders'] + 1;
} else {
$result['skipped_orders'] = (int) $result['skipped_orders'] + 1;
}
} catch (Throwable $exception) {
$result['failed_orders'] = (int) $result['failed_orders'] + 1;
$errors = is_array($result['errors']) ? $result['errors'] : [];
if (count($errors) < 20) {
$errors[] = [
'integration_id' => $integrationId,
'order_id' => (int) ($order['id'] ?? 0),
'source_order_id' => $sourceOrderId,
'error' => $exception->getMessage(),
];
}
$result['errors'] = $errors;
}
}
}
return $result;
}
/**
* @param array<string, mixed> $integration
* @return array<int, string>
*/
private function resolveWatchedStatusCodes(array $integration): array
{
$rawCodes = $integration['payment_sync_status_codes'] ?? [];
if (!is_array($rawCodes)) {
return [];
}
$result = [];
$seen = [];
foreach ($rawCodes as $rawCode) {
$code = strtolower(trim((string) $rawCode));
if ($code === '' || isset($seen[$code])) {
continue;
}
$seen[$code] = true;
$result[] = $code;
}
return $result;
}
/**
* @param array<int, string> $watchedStatuses
* @return array<int, array<string, mixed>>
*/
private function findCandidateOrders(int $integrationId, array $watchedStatuses, int $limit): array
{
$where = [
'source = :source',
'integration_id = :integration_id',
'source_order_id IS NOT NULL',
'source_order_id <> ""',
'(payment_status IS NULL OR payment_status <> :paid_status)',
];
$params = [
'source' => 'shoppro',
'integration_id' => $integrationId,
'paid_status' => self::PAID_STATUS,
];
$statusPlaceholders = [];
$statusCodes = $watchedStatuses !== [] ? $watchedStatuses : self::DEFAULT_FINAL_STATUS_CODES;
foreach ($statusCodes as $index => $statusCode) {
$placeholder = ':status_' . $index;
$statusPlaceholders[] = $placeholder;
$params['status_' . $index] = strtolower($statusCode);
}
if ($watchedStatuses !== []) {
$where[] = 'LOWER(COALESCE(external_status_id, "")) IN (' . implode(', ', $statusPlaceholders) . ')';
} else {
$where[] = 'LOWER(COALESCE(external_status_id, "")) NOT IN (' . implode(', ', $statusPlaceholders) . ')';
}
$sql = 'SELECT id, source_order_id, payment_status, total_paid, total_with_tax, currency, external_payment_type_id
FROM orders
WHERE ' . implode(' AND ', $where) . '
ORDER BY source_updated_at DESC, id DESC
LIMIT :limit';
$stmt = $this->pdo->prepare($sql);
foreach ($params as $key => $value) {
if (is_int($value)) {
$stmt->bindValue(':' . $key, $value, PDO::PARAM_INT);
continue;
}
$stmt->bindValue(':' . $key, $value);
}
$stmt->bindValue(':limit', max(1, min(1000, $limit)), PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @param array<string, mixed> $order
*/
private function syncSingleOrderPayment(
int $integrationId,
string $baseUrl,
string $apiKey,
int $timeout,
array $order
): bool {
$sourceOrderId = trim((string) ($order['source_order_id'] ?? ''));
if ($sourceOrderId === '') {
return false;
}
$details = $this->apiClient->fetchOrderById($baseUrl, $apiKey, $timeout, $sourceOrderId);
if (($details['ok'] ?? false) !== true || !is_array($details['order'] ?? null)) {
throw new \RuntimeException((string) ($details['message'] ?? 'Blad pobierania szczegolow zamowienia.'));
}
$payload = (array) $details['order'];
$isPaid = $this->resolvePaidFlag($payload);
if ($isPaid === null) {
return false;
}
$newPaymentStatus = $isPaid ? self::PAID_STATUS : self::UNPAID_STATUS;
$existingTotalWithTax = $order['total_with_tax'] !== null ? (float) $order['total_with_tax'] : null;
$newTotalPaid = $isPaid
? $this->resolvePaidAmount($payload, $existingTotalWithTax)
: 0.0;
$existingPaymentStatus = isset($order['payment_status']) ? (int) $order['payment_status'] : null;
$existingTotalPaid = $order['total_paid'] !== null ? (float) $order['total_paid'] : null;
$paymentMethod = $this->nullableString((string) ($payload['payment_method'] ?? $order['external_payment_type_id'] ?? ''));
$paymentDate = $this->normalizeDateTime((string) ($payload['payment_date'] ?? ''));
$sourceUpdatedAt = $this->normalizeDateTime((string) ($payload['updated_at'] ?? $payload['date_updated'] ?? ''));
if (
$existingPaymentStatus === $newPaymentStatus
&& $this->floatsEqual($existingTotalPaid, $newTotalPaid)
&& $paymentMethod === $this->nullableString((string) ($order['external_payment_type_id'] ?? ''))
) {
return false;
}
$orderId = (int) ($order['id'] ?? 0);
if ($orderId <= 0) {
return false;
}
$this->pdo->beginTransaction();
try {
$this->updateOrderPaymentColumns($orderId, $newPaymentStatus, $newTotalPaid, $paymentMethod, $sourceUpdatedAt);
$this->replaceOrderPaymentRow($orderId, $paymentMethod, $paymentDate, $newTotalPaid, (string) ($order['currency'] ?? 'PLN'), $isPaid);
$this->pdo->commit();
} catch (Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $exception;
}
$summary = $isPaid
? 'shopPRO: zamowienie oznaczone jako oplacone'
: 'shopPRO: zamowienie oznaczone jako nieoplacone';
$this->orders->recordActivity(
$orderId,
'payment',
$summary,
[
'integration_id' => $integrationId,
'source_order_id' => $sourceOrderId,
'old_payment_status' => $existingPaymentStatus,
'new_payment_status' => $newPaymentStatus,
'old_total_paid' => $existingTotalPaid,
'new_total_paid' => $newTotalPaid,
],
'sync',
'shopPRO'
);
return true;
}
private function updateOrderPaymentColumns(
int $orderId,
int $paymentStatus,
?float $totalPaid,
?string $paymentMethod,
?string $sourceUpdatedAt
): void {
$sql = 'UPDATE orders
SET payment_status = :payment_status,
total_paid = :total_paid,
external_payment_type_id = :external_payment_type_id,
fetched_at = NOW(),
updated_at = NOW()';
$params = [
'id' => $orderId,
'payment_status' => $paymentStatus,
'total_paid' => $totalPaid,
'external_payment_type_id' => $paymentMethod,
];
if ($sourceUpdatedAt !== null) {
$sql .= ', source_updated_at = :source_updated_at';
$params['source_updated_at'] = $sourceUpdatedAt;
}
$sql .= ' WHERE id = :id';
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
}
private function replaceOrderPaymentRow(
int $orderId,
?string $paymentMethod,
?string $paymentDate,
?float $amount,
string $currency,
bool $isPaid
): void {
$deleteStmt = $this->pdo->prepare('DELETE FROM order_payments WHERE order_id = :order_id');
$deleteStmt->execute(['order_id' => $orderId]);
$insertStmt = $this->pdo->prepare(
'INSERT INTO order_payments (
order_id, source_payment_id, external_payment_id, payment_type_id, payment_date, amount, currency, comment, payload_json
) VALUES (
:order_id, NULL, NULL, :payment_type_id, :payment_date, :amount, :currency, :comment, NULL
)'
);
$insertStmt->execute([
'order_id' => $orderId,
'payment_type_id' => $paymentMethod ?? 'unknown',
'payment_date' => $paymentDate,
'amount' => $amount,
'currency' => trim($currency) !== '' ? strtoupper($currency) : 'PLN',
'comment' => $isPaid ? 'paid' : 'unpaid',
]);
}
private function resolvePaidFlag(array $payload): ?bool
{
$raw = $payload['paid'] ?? $payload['is_paid'] ?? null;
if ($raw === null) {
return null;
}
if (is_bool($raw)) {
return $raw;
}
$value = strtolower(trim((string) $raw));
if (in_array($value, ['1', 'true', 'yes', 'paid'], true)) {
return true;
}
if (in_array($value, ['0', 'false', 'no', 'unpaid'], true)) {
return false;
}
return null;
}
private function resolvePaidAmount(array $payload, ?float $fallbackGross): ?float
{
$value = $payload['total_paid'] ?? null;
if ($value !== null && is_numeric((string) $value)) {
return (float) $value;
}
$grossCandidates = [
$payload['total_gross'] ?? null,
$payload['total_with_tax'] ?? null,
$payload['summary']['total'] ?? null,
$payload['summary'] ?? null,
];
foreach ($grossCandidates as $candidate) {
if ($candidate !== null && is_numeric((string) $candidate)) {
return (float) $candidate;
}
}
return $fallbackGross;
}
private function normalizeDateTime(string $value): ?string
{
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
try {
return (new DateTimeImmutable($trimmed))->format('Y-m-d H:i:s');
} catch (Throwable) {
return null;
}
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function floatsEqual(?float $left, ?float $right): bool
{
if ($left === null && $right === null) {
return true;
}
if ($left === null || $right === null) {
return false;
}
return abs($left - $right) < 0.00001;
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
final class ShopproStatusMappingRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return array<int, array{shoppro_status_code:string,shoppro_status_name:string,orderpro_status_code:string}>
*/
public function listByIntegration(int $integrationId): array
{
if ($integrationId <= 0) {
return [];
}
$statement = $this->pdo->prepare(
'SELECT shoppro_status_code, shoppro_status_name, orderpro_status_code
FROM order_status_mappings
WHERE integration_id = :integration_id
ORDER BY shoppro_status_code ASC'
);
$statement->execute(['integration_id' => $integrationId]);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$shopproCode = trim((string) ($row['shoppro_status_code'] ?? ''));
if ($shopproCode === '') {
continue;
}
$result[] = [
'shoppro_status_code' => $shopproCode,
'shoppro_status_name' => trim((string) ($row['shoppro_status_name'] ?? '')),
'orderpro_status_code' => trim((string) ($row['orderpro_status_code'] ?? '')),
];
}
return $result;
}
/**
* @param array<int, array{shoppro_status_code:string,shoppro_status_name:string,orderpro_status_code:string}> $mappings
*/
public function replaceForIntegration(int $integrationId, array $mappings): void
{
if ($integrationId <= 0) {
return;
}
$deleteStatement = $this->pdo->prepare(
'DELETE FROM order_status_mappings WHERE integration_id = :integration_id'
);
$deleteStatement->execute(['integration_id' => $integrationId]);
if ($mappings === []) {
return;
}
$insertStatement = $this->pdo->prepare(
'INSERT INTO order_status_mappings (
integration_id, shoppro_status_code, shoppro_status_name, orderpro_status_code, created_at, updated_at
) VALUES (
:integration_id, :shoppro_status_code, :shoppro_status_name, :orderpro_status_code, NOW(), NOW()
)'
);
foreach ($mappings as $mapping) {
$shopproCode = trim((string) ($mapping['shoppro_status_code'] ?? ''));
$orderproCode = trim((string) ($mapping['orderpro_status_code'] ?? ''));
if ($shopproCode === '' || $orderproCode === '') {
continue;
}
$shopproName = trim((string) ($mapping['shoppro_status_name'] ?? ''));
$insertStatement->execute([
'integration_id' => $integrationId,
'shoppro_status_code' => $shopproCode,
'shoppro_status_name' => $shopproName !== '' ? $shopproName : null,
'orderpro_status_code' => $orderproCode,
]);
}
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
final class ShopproStatusSyncService
{
private const DIRECTION_SHOPPRO_TO_ORDERPRO = 'shoppro_to_orderpro';
private const DIRECTION_ORDERPRO_TO_SHOPPRO = 'orderpro_to_shoppro';
public function __construct(
private readonly ShopproIntegrationsRepository $integrations,
private readonly ShopproOrdersSyncService $ordersSyncService
) {
}
/**
* @return array<string, mixed>
*/
public function sync(): array
{
$supportedIntegrationIds = [];
$unsupportedCount = 0;
foreach ($this->integrations->listIntegrations() as $integration) {
$integrationId = (int) ($integration['id'] ?? 0);
if ($integrationId <= 0 || empty($integration['is_active']) || empty($integration['has_api_key'])) {
continue;
}
$direction = trim((string) ($integration['order_status_sync_direction'] ?? self::DIRECTION_SHOPPRO_TO_ORDERPRO));
if ($direction === self::DIRECTION_ORDERPRO_TO_SHOPPRO) {
$unsupportedCount++;
continue;
}
$supportedIntegrationIds[] = $integrationId;
}
if ($supportedIntegrationIds === []) {
return [
'ok' => true,
'processed' => 0,
'checked_integrations' => 0,
'unsupported_integrations' => $unsupportedCount,
'message' => 'Brak aktywnych integracji shopPRO z kierunkiem shopPRO -> orderPRO.',
];
}
$result = $this->ordersSyncService->sync([
'max_pages' => 3,
'page_limit' => 50,
'max_orders' => 200,
'ignore_orders_fetch_enabled' => true,
'allowed_integration_ids' => $supportedIntegrationIds,
]);
$result['ok'] = true;
$result['direction'] = self::DIRECTION_SHOPPRO_TO_ORDERPRO;
$result['unsupported_integrations'] = $unsupportedCount;
return $result;
}
}