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:
@@ -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);
|
||||
|
||||
@@ -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>>
|
||||
*/
|
||||
|
||||
26
src/Modules/Cron/ShopproOrdersImportHandler.php
Normal file
26
src/Modules/Cron/ShopproOrdersImportHandler.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
24
src/Modules/Cron/ShopproPaymentStatusSyncHandler.php
Normal file
24
src/Modules/Cron/ShopproPaymentStatusSyncHandler.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
22
src/Modules/Cron/ShopproStatusSyncHandler.php
Normal file
22
src/Modules/Cron/ShopproStatusSyncHandler.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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>';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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 : '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
65
src/Modules/Settings/IntegrationSecretCipher.php
Normal file
65
src/Modules/Settings/IntegrationSecretCipher.php
Normal 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;
|
||||
}
|
||||
}
|
||||
169
src/Modules/Settings/IntegrationsHubController.php
Normal file
169
src/Modules/Settings/IntegrationsHubController.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
155
src/Modules/Settings/IntegrationsRepository.php
Normal file
155
src/Modules/Settings/IntegrationsRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
263
src/Modules/Settings/ShopproApiClient.php
Normal file
263
src/Modules/Settings/ShopproApiClient.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
104
src/Modules/Settings/ShopproDeliveryMethodMappingRepository.php
Normal file
104
src/Modules/Settings/ShopproDeliveryMethodMappingRepository.php
Normal 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 : [];
|
||||
}
|
||||
}
|
||||
868
src/Modules/Settings/ShopproIntegrationsController.php
Normal file
868
src/Modules/Settings/ShopproIntegrationsController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
591
src/Modules/Settings/ShopproIntegrationsRepository.php
Normal file
591
src/Modules/Settings/ShopproIntegrationsRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
1074
src/Modules/Settings/ShopproOrdersSyncService.php
Normal file
1074
src/Modules/Settings/ShopproOrdersSyncService.php
Normal file
File diff suppressed because it is too large
Load Diff
404
src/Modules/Settings/ShopproPaymentStatusSyncService.php
Normal file
404
src/Modules/Settings/ShopproPaymentStatusSyncService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
99
src/Modules/Settings/ShopproStatusMappingRepository.php
Normal file
99
src/Modules/Settings/ShopproStatusMappingRepository.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/Modules/Settings/ShopproStatusSyncService.php
Normal file
63
src/Modules/Settings/ShopproStatusSyncService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user