> $rows
+ */
+ private function replaceStatusHistory(int $orderId, array $rows): void
+ {
+ $this->pdo->prepare('DELETE FROM order_status_history WHERE order_id = :order_id')->execute(['order_id' => $orderId]);
+ if ($rows === []) {
+ return;
+ }
+
+ $statement = $this->pdo->prepare(
+ 'INSERT INTO order_status_history (
+ order_id, from_status_id, to_status_id, changed_at, change_source, comment, payload_json
+ ) VALUES (
+ :order_id, :from_status_id, :to_status_id, :changed_at, :change_source, :comment, :payload_json
+ )'
+ );
+
+ foreach ($rows as $row) {
+ $statement->execute([
+ 'order_id' => $orderId,
+ 'from_status_id' => $row['from_status_id'] ?? null,
+ 'to_status_id' => (string) ($row['to_status_id'] ?? ''),
+ 'changed_at' => $row['changed_at'] ?? date('Y-m-d H:i:s'),
+ 'change_source' => (string) ($row['change_source'] ?? 'import'),
+ 'comment' => $row['comment'] ?? null,
+ 'payload_json' => $this->encodeJson($row['payload_json'] ?? null),
+ ]);
+ }
+ }
+
+ private function encodeJson(mixed $value): ?string
+ {
+ if ($value === null || $value === '') {
+ return null;
+ }
+ if (!is_array($value)) {
+ return null;
+ }
+
+ return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: null;
+ }
+}
diff --git a/src/Modules/Orders/OrdersController.php b/src/Modules/Orders/OrdersController.php
index f1604b3..c65b5c2 100644
--- a/src/Modules/Orders/OrdersController.php
+++ b/src/Modules/Orders/OrdersController.php
@@ -39,11 +39,11 @@ final class OrdersController
$result = $this->orders->paginate($filters);
$totalPages = max(1, (int) ceil(((int) $result['total']) / max(1, (int) $result['per_page'])));
$sourceOptions = $this->orders->sourceOptions();
- $statusOptions = $this->orders->statusOptions();
$stats = $this->orders->quickStats();
$statusCounts = $this->orders->statusCounts();
$statusConfig = $this->orders->statusPanelConfig();
$statusLabelMap = $this->statusLabelMap($statusConfig);
+ $statusOptions = $this->buildStatusFilterOptions($this->orders->statusOptions(), $statusLabelMap);
$statusPanel = $this->buildStatusPanel($statusConfig, $statusCounts, $filters['status'], $filters);
$tableRows = array_map(fn (array $row): array => $this->toTableRow($row, $statusLabelMap), (array) ($result['items'] ?? []));
@@ -144,7 +144,7 @@ final class OrdersController
$documents = is_array($details['documents'] ?? null) ? $details['documents'] : [];
$notes = is_array($details['notes'] ?? null) ? $details['notes'] : [];
$history = is_array($details['status_history'] ?? null) ? $details['status_history'] : [];
- $statusCode = (string) ($order['external_status_id'] ?? '');
+ $statusCode = (string) (($order['effective_status_id'] ?? '') !== '' ? $order['effective_status_id'] : ($order['external_status_id'] ?? ''));
$statusCounts = $this->orders->statusCounts();
$statusConfig = $this->orders->statusPanelConfig();
$statusLabelMap = $this->statusLabelMap($statusConfig);
@@ -183,7 +183,7 @@ final class OrdersController
$buyerName = trim((string) ($row['buyer_name'] ?? ''));
$buyerEmail = trim((string) ($row['buyer_email'] ?? ''));
$buyerCity = trim((string) ($row['buyer_city'] ?? ''));
- $status = trim((string) ($row['external_status_id'] ?? ''));
+ $status = trim((string) (($row['effective_status_id'] ?? '') !== '' ? $row['effective_status_id'] : ($row['external_status_id'] ?? '')));
$currency = trim((string) ($row['currency'] ?? ''));
$totalWithTax = $row['total_with_tax'] !== null ? number_format((float) $row['total_with_tax'], 2, '.', ' ') : '-';
$totalPaid = $row['total_paid'] !== null ? number_format((float) $row['total_paid'], 2, '.', ' ') : '-';
@@ -211,7 +211,7 @@ final class OrdersController
. ''
. '',
'status_badges' => ''
- . $this->statusBadge($this->statusLabel($status, $statusLabelMap))
+ . $this->statusBadge($status, $this->statusLabel($status, $statusLabelMap))
. '
',
'products' => $this->productsHtml($itemsPreview, $itemsCount, $itemsQty),
'totals' => ''
@@ -227,17 +227,18 @@ final class OrdersController
];
}
- private function statusBadge(string $status): string
+ private function statusBadge(string $statusCode, string $statusLabel): string
{
- $label = $status !== '' ? $status : '-';
+ $label = $statusLabel !== '' ? $statusLabel : '-';
+ $code = strtolower(trim($statusCode));
$class = 'is-neutral';
- if (in_array($status, ['shipped', 'delivered'], true)) {
+ if (in_array($code, ['shipped', 'delivered'], true)) {
$class = 'is-success';
- } elseif (in_array($status, ['cancelled', 'returned'], true)) {
+ } elseif (in_array($code, ['cancelled', 'returned'], true)) {
$class = 'is-danger';
- } elseif (in_array($status, ['new', 'confirmed'], true)) {
+ } elseif (in_array($code, ['new', 'confirmed'], true)) {
$class = 'is-info';
- } elseif (in_array($status, ['processing', 'packed', 'paid'], true)) {
+ } elseif (in_array($code, ['processing', 'packed', 'paid'], true)) {
$class = 'is-warn';
}
@@ -255,7 +256,8 @@ final class OrdersController
return (string) $statusLabelMap[$key];
}
- return ucfirst($statusCode);
+ $normalized = str_replace(['_', '-'], ' ', $key);
+ return ucfirst($normalized);
}
/**
@@ -415,6 +417,26 @@ final class OrdersController
return $map;
}
+ /**
+ * @param array $statusCodes
+ * @param array $statusLabelMap
+ * @return array
+ */
+ private function buildStatusFilterOptions(array $statusCodes, array $statusLabelMap): array
+ {
+ $options = [];
+ foreach ($statusCodes as $code => $value) {
+ $rawCode = trim((string) ($code !== '' ? $code : $value));
+ if ($rawCode === '') {
+ continue;
+ }
+ $normalizedCode = strtolower($rawCode);
+ $options[$normalizedCode] = $this->statusLabel($normalizedCode, $statusLabelMap);
+ }
+
+ return $options;
+ }
+
/**
* @param array> $itemsPreview
*/
diff --git a/src/Modules/Orders/OrdersRepository.php b/src/Modules/Orders/OrdersRepository.php
index 5d2209d..14dc0b5 100644
--- a/src/Modules/Orders/OrdersRepository.php
+++ b/src/Modules/Orders/OrdersRepository.php
@@ -8,6 +8,8 @@ use Throwable;
final class OrdersRepository
{
+ private ?bool $supportsMappedMedia = null;
+
public function __construct(private readonly PDO $pdo)
{
}
@@ -24,6 +26,7 @@ final class OrdersRepository
$where = [];
$params = [];
+ $effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
$search = trim((string) ($filters['search'] ?? ''));
if ($search !== '') {
@@ -45,7 +48,7 @@ final class OrdersRepository
$status = trim((string) ($filters['status'] ?? ''));
if ($status !== '') {
- $where[] = 'o.external_status_id = :status';
+ $where[] = $effectiveStatusSql . ' = :status';
$params['status'] = $status;
}
@@ -86,7 +89,8 @@ final class OrdersRepository
try {
$countSql = 'SELECT COUNT(*) FROM orders o '
- . 'LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer"'
+ . 'LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer" '
+ . 'LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code'
. $whereSql;
$countStmt = $this->pdo->prepare($countSql);
$countStmt->execute($params);
@@ -98,6 +102,7 @@ final class OrdersRepository
o.source_order_id,
o.external_order_id,
o.external_status_id,
+ ' . $effectiveStatusSql . ' AS effective_status_id,
o.payment_status,
o.currency,
o.total_with_tax,
@@ -115,7 +120,8 @@ final class OrdersRepository
(SELECT COUNT(*) FROM order_shipments sh WHERE sh.order_id = o.id) AS shipments_count,
(SELECT COUNT(*) FROM order_documents od WHERE od.order_id = o.id) AS documents_count
FROM orders o
- LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer"'
+ LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer"
+ LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code'
. $whereSql
. ' ORDER BY ' . $sortColumn . ' ' . $sortDir
. ' LIMIT :limit OFFSET :offset';
@@ -150,6 +156,7 @@ final class OrdersRepository
'source_order_id' => (string) ($row['source_order_id'] ?? ''),
'external_order_id' => (string) ($row['external_order_id'] ?? ''),
'external_status_id' => (string) ($row['external_status_id'] ?? ''),
+ 'effective_status_id' => (string) ($row['effective_status_id'] ?? ''),
'payment_status' => isset($row['payment_status']) ? (int) $row['payment_status'] : null,
'currency' => (string) ($row['currency'] ?? ''),
'total_with_tax' => $row['total_with_tax'] !== null ? (float) $row['total_with_tax'] : null,
@@ -191,7 +198,15 @@ final class OrdersRepository
public function statusOptions(): array
{
try {
- $rows = $this->pdo->query('SELECT DISTINCT external_status_id FROM orders WHERE external_status_id IS NOT NULL AND external_status_id <> "" ORDER BY external_status_id ASC')->fetchAll(PDO::FETCH_COLUMN);
+ $effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
+ $rows = $this->pdo->query(
+ 'SELECT DISTINCT ' . $effectiveStatusSql . ' AS effective_status_id
+ FROM orders o
+ LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code
+ WHERE ' . $effectiveStatusSql . ' IS NOT NULL
+ AND ' . $effectiveStatusSql . ' <> ""
+ ORDER BY effective_status_id ASC'
+ )->fetchAll(PDO::FETCH_COLUMN);
} catch (Throwable) {
return [];
}
@@ -245,11 +260,13 @@ final class OrdersRepository
public function quickStats(): array
{
try {
+ $effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
$row = $this->pdo->query('SELECT
COUNT(*) AS all_count,
SUM(CASE WHEN payment_status = 2 THEN 1 ELSE 0 END) AS paid_count,
- SUM(CASE WHEN external_status_id IN ("shipped", "delivered", "returned") THEN 1 ELSE 0 END) AS shipped_count
- FROM orders')->fetch(PDO::FETCH_ASSOC);
+ SUM(CASE WHEN ' . $effectiveStatusSql . ' IN ("shipped", "delivered", "returned") THEN 1 ELSE 0 END) AS shipped_count
+ FROM orders o
+ LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code')->fetch(PDO::FETCH_ASSOC);
} catch (Throwable) {
return [
'all' => 0,
@@ -279,7 +296,13 @@ final class OrdersRepository
public function statusCounts(): array
{
try {
- $rows = $this->pdo->query('SELECT external_status_id, COUNT(*) AS cnt FROM orders GROUP BY external_status_id')->fetchAll(PDO::FETCH_ASSOC);
+ $effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
+ $rows = $this->pdo->query(
+ 'SELECT ' . $effectiveStatusSql . ' AS effective_status_id, COUNT(*) AS cnt
+ FROM orders o
+ LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code
+ GROUP BY effective_status_id'
+ )->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable) {
return [];
}
@@ -290,7 +313,7 @@ final class OrdersRepository
$result = [];
foreach ($rows as $row) {
- $key = trim((string) ($row['external_status_id'] ?? ''));
+ $key = trim((string) ($row['effective_status_id'] ?? ''));
if ($key === '') {
$key = '_empty';
}
@@ -366,7 +389,14 @@ final class OrdersRepository
}
try {
- $orderStmt = $this->pdo->prepare('SELECT * FROM orders WHERE id = :id LIMIT 1');
+ $effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
+ $orderStmt = $this->pdo->prepare(
+ 'SELECT o.*, ' . $effectiveStatusSql . ' AS effective_status_id
+ FROM orders o
+ LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code
+ WHERE o.id = :id
+ LIMIT 1'
+ );
$orderStmt->execute(['id' => $orderId]);
$order = $orderStmt->fetch(PDO::FETCH_ASSOC);
if (!is_array($order)) {
@@ -380,12 +410,25 @@ final class OrdersRepository
$addresses = [];
}
- $itemsStmt = $this->pdo->prepare('SELECT * FROM order_items WHERE order_id = :order_id ORDER BY sort_order ASC, id ASC');
+ $itemsMediaSql = $this->resolvedMediaUrlSql('oi');
+ $itemsStmt = $this->pdo->prepare('SELECT oi.*, ' . $itemsMediaSql . ' AS resolved_media_url
+ FROM order_items oi
+ WHERE oi.order_id = :order_id
+ ORDER BY oi.sort_order ASC, oi.id ASC');
$itemsStmt->execute(['order_id' => $orderId]);
$items = $itemsStmt->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($items)) {
$items = [];
}
+ $items = array_map(static function (array $row): array {
+ $resolvedMediaUrl = trim((string) ($row['resolved_media_url'] ?? ''));
+ if ($resolvedMediaUrl !== '') {
+ $row['media_url'] = $resolvedMediaUrl;
+ }
+ unset($row['resolved_media_url']);
+
+ return $row;
+ }, $items);
$paymentsStmt = $this->pdo->prepare('SELECT * FROM order_payments WHERE order_id = :order_id ORDER BY payment_date ASC, id ASC');
$paymentsStmt->execute(['order_id' => $orderId]);
@@ -457,10 +500,11 @@ final class OrdersRepository
$placeholders = implode(',', array_fill(0, count($cleanIds), '?'));
try {
- $sql = 'SELECT order_id, original_name, quantity, COALESCE(media_url, "") AS media_url, sort_order, id
- FROM order_items
- WHERE order_id IN (' . $placeholders . ')
- ORDER BY order_id ASC, sort_order ASC, id ASC';
+ $resolvedMediaSql = $this->resolvedMediaUrlSql('oi');
+ $sql = 'SELECT oi.order_id, oi.original_name, oi.quantity, ' . $resolvedMediaSql . ' AS media_url, oi.sort_order, oi.id
+ FROM order_items oi
+ WHERE oi.order_id IN (' . $placeholders . ')
+ ORDER BY oi.order_id ASC, oi.sort_order ASC, oi.id ASC';
$stmt = $this->pdo->prepare($sql);
foreach ($cleanIds as $index => $orderId) {
$stmt->bindValue($index + 1, $orderId, PDO::PARAM_INT);
@@ -496,6 +540,88 @@ final class OrdersRepository
return $result;
}
+ private function effectiveStatusSql(string $orderAlias, string $mappingAlias): string
+ {
+ return 'CASE
+ WHEN ' . $orderAlias . '.source = "allegro"
+ AND ' . $mappingAlias . '.orderpro_status_code IS NOT NULL
+ AND ' . $mappingAlias . '.orderpro_status_code <> ""
+ THEN ' . $mappingAlias . '.orderpro_status_code
+ ELSE ' . $orderAlias . '.external_status_id
+ END';
+ }
+
+ private function resolvedMediaUrlSql(string $itemAlias): string
+ {
+ if (!$this->canResolveMappedMedia()) {
+ return 'COALESCE(NULLIF(TRIM(' . $itemAlias . '.media_url), ""), "")';
+ }
+
+ return 'COALESCE(
+ NULLIF(TRIM(' . $itemAlias . '.media_url), ""),
+ (
+ SELECT NULLIF(TRIM(pi.storage_path), "")
+ 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"
+ AND (
+ pcm.external_product_id = ' . $itemAlias . '.external_item_id
+ OR pcm.external_product_id = ' . $itemAlias . '.source_product_id
+ )
+ ORDER BY pi.is_main DESC, pi.sort_order ASC, pi.id ASC
+ LIMIT 1
+ ),
+ ""
+ )';
+ }
+
+ private function canResolveMappedMedia(): bool
+ {
+ if ($this->supportsMappedMedia !== null) {
+ return $this->supportsMappedMedia;
+ }
+
+ try {
+ $requiredColumns = [
+ ['table' => 'product_channel_map', 'column' => 'product_id'],
+ ['table' => 'product_channel_map', 'column' => 'channel_id'],
+ ['table' => 'product_channel_map', 'column' => 'external_product_id'],
+ ['table' => 'sales_channels', 'column' => 'id'],
+ ['table' => 'sales_channels', 'column' => 'code'],
+ ['table' => 'product_images', 'column' => 'id'],
+ ['table' => 'product_images', 'column' => 'product_id'],
+ ['table' => 'product_images', 'column' => 'storage_path'],
+ ['table' => 'product_images', 'column' => 'sort_order'],
+ ['table' => 'product_images', 'column' => 'is_main'],
+ ];
+
+ $pairsSql = [];
+ $params = [];
+ foreach ($requiredColumns as $index => $required) {
+ $tableParam = ':table_' . $index;
+ $columnParam = ':column_' . $index;
+ $pairsSql[] = '(TABLE_NAME = ' . $tableParam . ' AND COLUMN_NAME = ' . $columnParam . ')';
+ $params['table_' . $index] = $required['table'];
+ $params['column_' . $index] = $required['column'];
+ }
+
+ $sql = 'SELECT COUNT(*) AS cnt
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND (' . implode(' OR ', $pairsSql) . ')';
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute($params);
+ $count = (int) $stmt->fetchColumn();
+
+ $this->supportsMappedMedia = ($count === count($requiredColumns));
+ } catch (Throwable) {
+ $this->supportsMappedMedia = false;
+ }
+
+ return $this->supportsMappedMedia;
+ }
+
private function normalizeColorHex(string $value): string
{
$trimmed = trim($value);
diff --git a/src/Modules/Settings/AllegroApiClient.php b/src/Modules/Settings/AllegroApiClient.php
new file mode 100644
index 0000000..27b3d82
--- /dev/null
+++ b/src/Modules/Settings/AllegroApiClient.php
@@ -0,0 +1,108 @@
+
+ */
+ public function getCheckoutForm(string $environment, string $accessToken, string $checkoutFormId): array
+ {
+ $safeId = rawurlencode(trim($checkoutFormId));
+ if ($safeId === '') {
+ throw new RuntimeException('Brak ID zamowienia Allegro do pobrania.');
+ }
+
+ $url = rtrim($this->apiBaseUrl($environment), '/') . '/order/checkout-forms/' . $safeId;
+ return $this->requestJson($url, $accessToken);
+ }
+
+ /**
+ * @return array
+ */
+ public function listCheckoutForms(string $environment, string $accessToken, int $limit, int $offset): array
+ {
+ $safeLimit = max(1, min(100, $limit));
+ $safeOffset = max(0, $offset);
+ $query = http_build_query([
+ 'limit' => $safeLimit,
+ 'offset' => $safeOffset,
+ 'sort' => '-updatedAt',
+ ]);
+
+ $url = rtrim($this->apiBaseUrl($environment), '/') . '/order/checkout-forms?' . $query;
+ return $this->requestJson($url, $accessToken);
+ }
+
+ /**
+ * @return array
+ */
+ public function getProductOffer(string $environment, string $accessToken, string $offerId): array
+ {
+ $safeId = rawurlencode(trim($offerId));
+ if ($safeId === '') {
+ throw new RuntimeException('Brak ID oferty Allegro do pobrania.');
+ }
+
+ $url = rtrim($this->apiBaseUrl($environment), '/') . '/sale/product-offers/' . $safeId;
+ return $this->requestJson($url, $accessToken);
+ }
+
+ private function apiBaseUrl(string $environment): string
+ {
+ return trim(strtolower($environment)) === 'production'
+ ? 'https://api.allegro.pl'
+ : 'https://api.allegro.pl.allegrosandbox.pl';
+ }
+
+ /**
+ * @return array
+ */
+ private function requestJson(string $url, string $accessToken): array
+ {
+ $ch = curl_init($url);
+ if ($ch === false) {
+ throw new RuntimeException('Nie udalo sie zainicjowac polaczenia z API Allegro.');
+ }
+
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HTTPGET => true,
+ CURLOPT_TIMEOUT => 30,
+ CURLOPT_CONNECTTIMEOUT => 10,
+ CURLOPT_HTTPHEADER => [
+ 'Accept: application/vnd.allegro.public.v1+json',
+ 'Authorization: Bearer ' . $accessToken,
+ ],
+ ]);
+
+ $responseBody = curl_exec($ch);
+ $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $curlError = curl_error($ch);
+ $ch = null;
+
+ if ($responseBody === false) {
+ throw new RuntimeException('Blad polaczenia z API Allegro: ' . $curlError);
+ }
+
+ $json = json_decode((string) $responseBody, true);
+ if (!is_array($json)) {
+ throw new RuntimeException('Nieprawidlowy JSON odpowiedzi API Allegro.');
+ }
+
+ if ($httpCode === 401) {
+ throw new RuntimeException('ALLEGRO_HTTP_401');
+ }
+
+ if ($httpCode < 200 || $httpCode >= 300) {
+ $message = trim((string) ($json['message'] ?? 'Blad API Allegro.'));
+ throw new RuntimeException('API Allegro HTTP ' . $httpCode . ': ' . $message);
+ }
+
+ return $json;
+ }
+}
diff --git a/src/Modules/Settings/AllegroIntegrationController.php b/src/Modules/Settings/AllegroIntegrationController.php
new file mode 100644
index 0000000..1ea1224
--- /dev/null
+++ b/src/Modules/Settings/AllegroIntegrationController.php
@@ -0,0 +1,703 @@
+ 5,
+ 'page_limit' => 50,
+ 'max_orders' => 200,
+ ];
+ private const OAUTH_SCOPES = [
+ AllegroOAuthClient::ORDERS_READ_SCOPE,
+ AllegroOAuthClient::SALE_OFFERS_READ_SCOPE,
+ ];
+
+ public function __construct(
+ private readonly Template $template,
+ private readonly Translator $translator,
+ private readonly AuthService $auth,
+ private readonly AllegroIntegrationRepository $repository,
+ private readonly AllegroStatusMappingRepository $statusMappings,
+ private readonly OrderStatusRepository $orderStatuses,
+ private readonly CronRepository $cronRepository,
+ private readonly AllegroOAuthClient $oauthClient,
+ private readonly AllegroOrderImportService $orderImportService,
+ private readonly AllegroStatusDiscoveryService $statusDiscoveryService,
+ private readonly string $appUrl
+ ) {
+ }
+
+ public function index(Request $request): Response
+ {
+ $settings = $this->repository->getSettings();
+ $tab = trim((string) $request->input('tab', 'integration'));
+ if (!in_array($tab, ['integration', 'statuses', 'settings'], true)) {
+ $tab = 'integration';
+ }
+ $defaultRedirectUri = $this->defaultRedirectUri();
+
+ if (trim((string) ($settings['redirect_uri'] ?? '')) === '') {
+ $settings['redirect_uri'] = $defaultRedirectUri;
+ }
+ $importIntervalSeconds = $this->currentImportIntervalSeconds();
+ $statusSyncDirection = $this->currentStatusSyncDirection();
+ $statusSyncIntervalMinutes = $this->currentStatusSyncIntervalMinutes();
+
+ $html = $this->template->render('settings/allegro', [
+ 'title' => $this->translator->get('settings.allegro.title'),
+ 'activeMenu' => 'settings',
+ 'activeSettings' => 'allegro',
+ 'user' => $this->auth->user(),
+ 'csrfToken' => Csrf::token(),
+ 'settings' => $settings,
+ 'activeTab' => $tab,
+ 'importIntervalSeconds' => $importIntervalSeconds,
+ 'statusSyncDirection' => $statusSyncDirection,
+ 'statusSyncIntervalMinutes' => $statusSyncIntervalMinutes,
+ 'statusMappings' => $this->statusMappings->listMappings(),
+ 'orderproStatuses' => $this->orderStatuses->listStatuses(),
+ 'defaultRedirectUri' => $defaultRedirectUri,
+ 'errorMessage' => (string) Flash::get('settings_error', ''),
+ 'successMessage' => (string) Flash::get('settings_success', ''),
+ 'warningMessage' => (string) Flash::get('settings_warning', ''),
+ ], 'layouts/app');
+
+ return Response::html($html);
+ }
+
+ public function save(Request $request): Response
+ {
+ $csrfError = $this->validateCsrf((string) $request->input('_token', ''));
+ if ($csrfError !== null) {
+ return $csrfError;
+ }
+
+ $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');
+ }
+
+ $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');
+ }
+
+ $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');
+ }
+
+ $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');
+ }
+
+ try {
+ $this->repository->saveSettings([
+ 'environment' => $environment,
+ 'client_id' => $clientId,
+ 'client_secret' => trim((string) $request->input('client_secret', '')),
+ 'redirect_uri' => $redirectUri,
+ 'orders_fetch_enabled' => (string) $request->input('orders_fetch_enabled', '0') === '1',
+ 'orders_fetch_start_date' => $ordersFetchStartDate,
+ ]);
+ Flash::set('settings_success', $this->translator->get('settings.allegro.flash.saved'));
+ } catch (Throwable $exception) {
+ Flash::set(
+ 'settings_error',
+ $this->translator->get('settings.allegro.flash.save_failed') . ' ' . $exception->getMessage()
+ );
+ }
+
+ return Response::redirect('/settings/integrations/allegro');
+ }
+
+ public function saveImportSettings(Request $request): Response
+ {
+ $csrfError = $this->validateCsrf((string) $request->input('_token', ''));
+ if ($csrfError !== null) {
+ return $csrfError;
+ }
+
+ $intervalMinutesRaw = (int) $request->input('orders_import_interval_minutes', 5);
+ $intervalMinutes = max(1, min(1440, $intervalMinutesRaw));
+ if ($intervalMinutesRaw !== $intervalMinutes) {
+ Flash::set('settings_error', $this->translator->get('settings.allegro.validation.orders_import_interval_invalid'));
+ return Response::redirect('/settings/integrations/allegro?tab=settings');
+ }
+
+ $statusSyncDirection = trim((string) $request->input(
+ 'status_sync_direction',
+ self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO
+ ));
+ if (!in_array($statusSyncDirection, $this->allowedStatusSyncDirections(), true)) {
+ Flash::set('settings_error', $this->translator->get('settings.allegro.validation.status_sync_direction_invalid'));
+ return Response::redirect('/settings/integrations/allegro?tab=settings');
+ }
+
+ $statusSyncIntervalRaw = (int) $request->input(
+ 'status_sync_interval_minutes',
+ self::STATUS_SYNC_DEFAULT_INTERVAL_MINUTES
+ );
+ $statusSyncInterval = max(1, min(1440, $statusSyncIntervalRaw));
+ if ($statusSyncIntervalRaw !== $statusSyncInterval) {
+ Flash::set('settings_error', $this->translator->get('settings.allegro.validation.status_sync_interval_invalid'));
+ return Response::redirect('/settings/integrations/allegro?tab=settings');
+ }
+
+ $existing = $this->findImportSchedule();
+ $priority = (int) ($existing['priority'] ?? self::ORDERS_IMPORT_DEFAULT_PRIORITY);
+ $maxAttempts = (int) ($existing['max_attempts'] ?? self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS);
+ $payload = is_array($existing['payload'] ?? null)
+ ? (array) $existing['payload']
+ : self::ORDERS_IMPORT_DEFAULT_PAYLOAD;
+ $enabled = array_key_exists('enabled', $existing)
+ ? (bool) $existing['enabled']
+ : true;
+ $statusSchedule = $this->findStatusSyncSchedule();
+ $statusPriority = (int) ($statusSchedule['priority'] ?? self::ORDERS_IMPORT_DEFAULT_PRIORITY);
+ $statusMaxAttempts = (int) ($statusSchedule['max_attempts'] ?? self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS);
+ $statusEnabled = array_key_exists('enabled', $statusSchedule)
+ ? (bool) $statusSchedule['enabled']
+ : true;
+
+ try {
+ $this->cronRepository->upsertSchedule(
+ self::ORDERS_IMPORT_JOB_TYPE,
+ $intervalMinutes * 60,
+ $priority,
+ $maxAttempts,
+ $payload,
+ $enabled
+ );
+ $this->cronRepository->upsertSchedule(
+ self::STATUS_SYNC_JOB_TYPE,
+ $statusSyncInterval * 60,
+ $statusPriority,
+ $statusMaxAttempts,
+ null,
+ $statusEnabled
+ );
+ $this->cronRepository->upsertSetting('allegro_status_sync_direction', $statusSyncDirection);
+ $this->cronRepository->upsertSetting('allegro_status_sync_interval_minutes', (string) $statusSyncInterval);
+ Flash::set('settings_success', $this->translator->get('settings.allegro.flash.import_settings_saved'));
+ } catch (Throwable $exception) {
+ Flash::set(
+ 'settings_error',
+ $this->translator->get('settings.allegro.flash.import_settings_save_failed') . ' ' . $exception->getMessage()
+ );
+ }
+
+ return Response::redirect('/settings/integrations/allegro?tab=settings');
+ }
+
+ public function saveStatusMapping(Request $request): Response
+ {
+ $csrfError = $this->validateCsrf((string) $request->input('_token', ''));
+ if ($csrfError !== null) {
+ return $csrfError;
+ }
+
+ $allegroStatusCode = strtolower(trim((string) $request->input('allegro_status_code', '')));
+ $orderproStatusCode = strtolower(trim((string) $request->input('orderpro_status_code', '')));
+ $allegroStatusName = trim((string) $request->input('allegro_status_name', ''));
+
+ if ($allegroStatusCode === '') {
+ Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.allegro_status_required'));
+ return Response::redirect('/settings/integrations/allegro?tab=statuses');
+ }
+
+ if ($orderproStatusCode === '') {
+ Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.orderpro_status_required'));
+ return Response::redirect('/settings/integrations/allegro?tab=statuses');
+ }
+
+ if (!$this->orderStatusCodeExists($orderproStatusCode)) {
+ Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.orderpro_status_not_found'));
+ return Response::redirect('/settings/integrations/allegro?tab=statuses');
+ }
+
+ try {
+ $this->statusMappings->upsertMapping($allegroStatusCode, $allegroStatusName !== '' ? $allegroStatusName : null, $orderproStatusCode);
+ Flash::set('settings_success', $this->translator->get('settings.allegro.statuses.flash.saved'));
+ } catch (Throwable $exception) {
+ Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.save_failed') . ' ' . $exception->getMessage());
+ }
+
+ return Response::redirect('/settings/integrations/allegro?tab=statuses');
+ }
+
+ public function saveStatusMappingsBulk(Request $request): Response
+ {
+ $csrfError = $this->validateCsrf((string) $request->input('_token', ''));
+ if ($csrfError !== null) {
+ return $csrfError;
+ }
+
+ $codes = $request->input('allegro_status_code', []);
+ $names = $request->input('allegro_status_name', []);
+ $selectedOrderproCodes = $request->input('orderpro_status_code', []);
+ if (!is_array($codes) || !is_array($names) || !is_array($selectedOrderproCodes)) {
+ Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.save_failed'));
+ return Response::redirect('/settings/integrations/allegro?tab=statuses');
+ }
+
+ try {
+ foreach ($codes as $index => $rawCode) {
+ $allegroStatusCode = strtolower(trim((string) $rawCode));
+ if ($allegroStatusCode === '') {
+ continue;
+ }
+
+ $allegroStatusName = trim((string) ($names[$index] ?? ''));
+ $orderproStatusCodeRaw = strtolower(trim((string) ($selectedOrderproCodes[$index] ?? '')));
+ $orderproStatusCode = $orderproStatusCodeRaw !== '' ? $orderproStatusCodeRaw : null;
+
+ if ($orderproStatusCode !== null && !$this->orderStatusCodeExists($orderproStatusCode)) {
+ Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.orderpro_status_not_found'));
+ return Response::redirect('/settings/integrations/allegro?tab=statuses');
+ }
+
+ $this->statusMappings->upsertMapping(
+ $allegroStatusCode,
+ $allegroStatusName !== '' ? $allegroStatusName : null,
+ $orderproStatusCode
+ );
+ }
+
+ Flash::set('settings_success', $this->translator->get('settings.allegro.statuses.flash.saved_bulk'));
+ } catch (Throwable $exception) {
+ Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.save_failed') . ' ' . $exception->getMessage());
+ }
+
+ return Response::redirect('/settings/integrations/allegro?tab=statuses');
+ }
+
+ public function deleteStatusMapping(Request $request): Response
+ {
+ $csrfError = $this->validateCsrf((string) $request->input('_token', ''));
+ if ($csrfError !== null) {
+ return $csrfError;
+ }
+
+ $mappingId = max(0, (int) $request->input('mapping_id', 0));
+ if ($mappingId <= 0) {
+ Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.mapping_not_found'));
+ return Response::redirect('/settings/integrations/allegro?tab=statuses');
+ }
+
+ try {
+ $this->statusMappings->deleteMappingById($mappingId);
+ Flash::set('settings_success', $this->translator->get('settings.allegro.statuses.flash.deleted'));
+ } catch (Throwable $exception) {
+ Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.delete_failed') . ' ' . $exception->getMessage());
+ }
+
+ return Response::redirect('/settings/integrations/allegro?tab=statuses');
+ }
+
+ public function syncStatusesFromAllegro(Request $request): Response
+ {
+ $csrfError = $this->validateCsrf((string) $request->input('_token', ''));
+ if ($csrfError !== null) {
+ return $csrfError;
+ }
+
+ try {
+ $result = $this->statusDiscoveryService->discoverAndStoreStatuses(5, 100);
+ Flash::set(
+ 'settings_success',
+ $this->translator->get('settings.allegro.statuses.flash.sync_ok', [
+ 'discovered' => (string) ((int) ($result['discovered'] ?? 0)),
+ 'samples' => (string) ((int) ($result['samples'] ?? 0)),
+ ])
+ );
+ } catch (Throwable $exception) {
+ Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.sync_failed') . ' ' . $exception->getMessage());
+ }
+
+ return Response::redirect('/settings/integrations/allegro?tab=statuses');
+ }
+
+ public function startOAuth(Request $request): Response
+ {
+ $csrfError = $this->validateCsrf((string) $request->input('_token', ''));
+ if ($csrfError !== null) {
+ return $csrfError;
+ }
+
+ try {
+ $credentials = $this->requireOAuthCredentials();
+ $state = bin2hex(random_bytes(24));
+ $_SESSION[self::OAUTH_STATE_SESSION_KEY] = $state;
+
+ $url = $this->oauthClient->buildAuthorizeUrl(
+ (string) $credentials['environment'],
+ (string) $credentials['client_id'],
+ (string) $credentials['redirect_uri'],
+ $state,
+ self::OAUTH_SCOPES
+ );
+
+ return Response::redirect($url);
+ } catch (Throwable $exception) {
+ Flash::set('settings_error', $exception->getMessage());
+ return Response::redirect('/settings/integrations/allegro');
+ }
+ }
+
+ public function oauthCallback(Request $request): Response
+ {
+ $error = trim((string) $request->input('error', ''));
+ if ($error !== '') {
+ $description = trim((string) $request->input('error_description', ''));
+ $message = $this->translator->get('settings.allegro.flash.oauth_failed');
+ if ($description !== '') {
+ $message .= ' ' . $description;
+ }
+ Flash::set('settings_error', $message);
+ return Response::redirect('/settings/integrations/allegro');
+ }
+
+ $state = trim((string) $request->input('state', ''));
+ $expectedState = trim((string) ($_SESSION[self::OAUTH_STATE_SESSION_KEY] ?? ''));
+ unset($_SESSION[self::OAUTH_STATE_SESSION_KEY]);
+ if ($state === '' || $expectedState === '' || !hash_equals($expectedState, $state)) {
+ Flash::set('settings_error', $this->translator->get('settings.allegro.flash.oauth_state_invalid'));
+ return Response::redirect('/settings/integrations/allegro');
+ }
+
+ $authorizationCode = trim((string) $request->input('code', ''));
+ if ($authorizationCode === '') {
+ Flash::set('settings_error', $this->translator->get('settings.allegro.flash.oauth_code_missing'));
+ return Response::redirect('/settings/integrations/allegro');
+ }
+
+ try {
+ $credentials = $this->requireOAuthCredentials();
+ $token = $this->oauthClient->exchangeAuthorizationCode(
+ (string) $credentials['environment'],
+ (string) $credentials['client_id'],
+ (string) $credentials['client_secret'],
+ (string) $credentials['redirect_uri'],
+ $authorizationCode
+ );
+
+ $expiresAt = null;
+ if ((int) ($token['expires_in'] ?? 0) > 0) {
+ $expiresAt = (new DateTimeImmutable('now'))
+ ->add(new DateInterval('PT' . (int) $token['expires_in'] . 'S'))
+ ->format('Y-m-d H:i:s');
+ }
+
+ $this->repository->saveTokens(
+ (string) ($token['access_token'] ?? ''),
+ (string) ($token['refresh_token'] ?? ''),
+ (string) ($token['token_type'] ?? ''),
+ (string) ($token['scope'] ?? ''),
+ $expiresAt
+ );
+
+ Flash::set('settings_success', $this->translator->get('settings.allegro.flash.oauth_connected'));
+ } catch (Throwable $exception) {
+ Flash::set('settings_error', $this->translator->get('settings.allegro.flash.oauth_failed') . ' ' . $exception->getMessage());
+ }
+
+ return Response::redirect('/settings/integrations/allegro');
+ }
+
+ public function importSingleOrder(Request $request): Response
+ {
+ $csrfError = $this->validateCsrf((string) $request->input('_token', ''));
+ if ($csrfError !== null) {
+ return $csrfError;
+ }
+
+ $checkoutFormId = trim((string) $request->input('checkout_form_id', ''));
+ if ($checkoutFormId === '') {
+ Flash::set('settings_error', $this->translator->get('settings.allegro.flash.checkout_form_id_required'));
+ return Response::redirect('/settings/integrations/allegro');
+ }
+
+ try {
+ $result = $this->orderImportService->importSingleOrder($checkoutFormId);
+ $imageDiagnostics = is_array($result['image_diagnostics'] ?? null) ? $result['image_diagnostics'] : [];
+ Flash::set(
+ 'settings_success',
+ $this->translator->get('settings.allegro.flash.import_single_ok', [
+ 'source_order_id' => (string) ($result['source_order_id'] ?? $checkoutFormId),
+ 'local_id' => (string) ((int) ($result['order_id'] ?? 0)),
+ 'action' => !empty($result['created'])
+ ? $this->translator->get('settings.allegro.import_action.created')
+ : $this->translator->get('settings.allegro.import_action.updated'),
+ ]) . ' '
+ . $this->translator->get('settings.allegro.flash.import_single_media_summary', [
+ 'with_image' => (string) ((int) ($imageDiagnostics['with_image'] ?? 0)),
+ 'total_items' => (string) ((int) ($imageDiagnostics['total_items'] ?? 0)),
+ 'without_image' => (string) ((int) ($imageDiagnostics['without_image'] ?? 0)),
+ ])
+ );
+
+ $warningDetails = $this->buildImportImageWarningMessage($imageDiagnostics);
+ if ($warningDetails !== '') {
+ Flash::set('settings_warning', $warningDetails);
+ }
+ } catch (Throwable $exception) {
+ Flash::set(
+ 'settings_error',
+ $this->translator->get('settings.allegro.flash.import_single_failed') . ' ' . $exception->getMessage()
+ );
+ }
+
+ return Response::redirect('/settings/integrations/allegro');
+ }
+
+ private function defaultRedirectUri(): string
+ {
+ $base = trim($this->appUrl);
+ if ($base === '') {
+ $base = 'http://localhost:8000';
+ }
+
+ return rtrim($base, '/') . '/settings/integrations/allegro/oauth/callback';
+ }
+
+ private function validateCsrf(string $token): ?Response
+ {
+ if (Csrf::validate($token)) {
+ return null;
+ }
+
+ Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
+ return Response::redirect('/settings/integrations/allegro');
+ }
+
+ /**
+ * @return array
+ */
+ private function requireOAuthCredentials(): array
+ {
+ $credentials = $this->repository->getOAuthCredentials();
+ if ($credentials === null) {
+ throw new RuntimeException($this->translator->get('settings.allegro.flash.credentials_missing'));
+ }
+
+ return $credentials;
+ }
+
+ private function isValidHttpUrl(string $url): bool
+ {
+ $trimmed = trim($url);
+ if ($trimmed === '') {
+ return false;
+ }
+
+ if (filter_var($trimmed, FILTER_VALIDATE_URL) === false) {
+ return false;
+ }
+
+ $scheme = strtolower((string) parse_url($trimmed, PHP_URL_SCHEME));
+ return $scheme === 'http' || $scheme === 'https';
+ }
+
+ private function isValidDate(string $value): bool
+ {
+ if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
+ return false;
+ }
+
+ $date = DateTimeImmutable::createFromFormat('Y-m-d', $value);
+ return $date instanceof DateTimeImmutable && $date->format('Y-m-d') === $value;
+ }
+
+ private function orderStatusCodeExists(string $code): bool
+ {
+ $needle = strtolower(trim($code));
+ if ($needle === '') {
+ return false;
+ }
+
+ foreach ($this->orderStatuses->listStatuses() as $row) {
+ $statusCode = strtolower(trim((string) ($row['code'] ?? '')));
+ if ($statusCode === $needle) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @param array $imageDiagnostics
+ */
+ private function buildImportImageWarningMessage(array $imageDiagnostics): string
+ {
+ $withoutImage = (int) ($imageDiagnostics['without_image'] ?? 0);
+ if ($withoutImage <= 0) {
+ return '';
+ }
+
+ $reasonCountsRaw = $imageDiagnostics['reason_counts'] ?? [];
+ if (!is_array($reasonCountsRaw) || $reasonCountsRaw === []) {
+ return $this->translator->get('settings.allegro.flash.import_single_media_warning_generic', [
+ 'without_image' => (string) $withoutImage,
+ ]);
+ }
+
+ $parts = [];
+ foreach ($reasonCountsRaw as $reason => $countRaw) {
+ $count = (int) $countRaw;
+ if ($count <= 0) {
+ continue;
+ }
+ $parts[] = $this->reasonLabel((string) $reason) . ': ' . $count;
+ }
+
+ if ($parts === []) {
+ return $this->translator->get('settings.allegro.flash.import_single_media_warning_generic', [
+ 'without_image' => (string) $withoutImage,
+ ]);
+ }
+
+ return $this->translator->get('settings.allegro.flash.import_single_media_warning', [
+ 'without_image' => (string) $withoutImage,
+ 'reasons' => implode(', ', $parts),
+ ]);
+ }
+
+ private function reasonLabel(string $reasonCode): string
+ {
+ return match ($reasonCode) {
+ 'missing_offer_id' => 'brak ID oferty',
+ 'missing_in_checkout_form' => 'brak obrazka w checkout form',
+ 'missing_in_offer_api' => 'brak obrazka w API oferty',
+ 'offer_api_access_denied_403' => 'brak uprawnien API ofert (403)',
+ 'offer_api_unauthorized_401' => 'token nieautoryzowany dla API ofert (401)',
+ 'offer_api_not_found_404' => 'oferta nie znaleziona (404)',
+ 'offer_api_request_failed' => 'blad zapytania do API oferty',
+ default => str_starts_with($reasonCode, 'offer_api_http_')
+ ? 'blad API oferty (' . str_replace('offer_api_http_', '', $reasonCode) . ')'
+ : $reasonCode,
+ };
+ }
+
+ private function currentImportIntervalSeconds(): int
+ {
+ $schedule = $this->findImportSchedule();
+ $value = (int) ($schedule['interval_seconds'] ?? self::ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS);
+ return max(60, min(86400, $value));
+ }
+
+ /**
+ * @return array
+ */
+ private function findImportSchedule(): array
+ {
+ try {
+ $schedules = $this->cronRepository->listSchedules();
+ } catch (Throwable) {
+ return [];
+ }
+
+ foreach ($schedules as $schedule) {
+ if (!is_array($schedule)) {
+ continue;
+ }
+ if ((string) ($schedule['job_type'] ?? '') !== self::ORDERS_IMPORT_JOB_TYPE) {
+ continue;
+ }
+ return $schedule;
+ }
+
+ return [];
+ }
+
+ /**
+ * @return array
+ */
+ private function findStatusSyncSchedule(): array
+ {
+ try {
+ $schedules = $this->cronRepository->listSchedules();
+ } catch (Throwable) {
+ return [];
+ }
+
+ foreach ($schedules as $schedule) {
+ if (!is_array($schedule)) {
+ continue;
+ }
+ if ((string) ($schedule['job_type'] ?? '') !== self::STATUS_SYNC_JOB_TYPE) {
+ continue;
+ }
+ return $schedule;
+ }
+
+ return [];
+ }
+
+ private function currentStatusSyncDirection(): string
+ {
+ $value = trim($this->cronRepository->getStringSetting(
+ 'allegro_status_sync_direction',
+ self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO
+ ));
+
+ if (!in_array($value, $this->allowedStatusSyncDirections(), true)) {
+ return self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO;
+ }
+
+ return $value;
+ }
+
+ private function currentStatusSyncIntervalMinutes(): int
+ {
+ return $this->cronRepository->getIntSetting(
+ 'allegro_status_sync_interval_minutes',
+ self::STATUS_SYNC_DEFAULT_INTERVAL_MINUTES,
+ 1,
+ 1440
+ );
+ }
+
+ /**
+ * @return array
+ */
+ private function allowedStatusSyncDirections(): array
+ {
+ return [
+ self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO,
+ self::STATUS_SYNC_DIRECTION_ORDERPRO_TO_ALLEGRO,
+ ];
+ }
+}
diff --git a/src/Modules/Settings/AllegroIntegrationRepository.php b/src/Modules/Settings/AllegroIntegrationRepository.php
new file mode 100644
index 0000000..3358a58
--- /dev/null
+++ b/src/Modules/Settings/AllegroIntegrationRepository.php
@@ -0,0 +1,320 @@
+
+ */
+ public function getSettings(): array
+ {
+ $row = $this->fetchRow();
+ if ($row === null) {
+ return $this->defaultSettings();
+ }
+
+ return [
+ 'environment' => $this->normalizeEnvironment((string) ($row['environment'] ?? 'sandbox')),
+ 'client_id' => trim((string) ($row['client_id'] ?? '')),
+ 'has_client_secret' => trim((string) ($row['client_secret_encrypted'] ?? '')) !== '',
+ 'redirect_uri' => trim((string) ($row['redirect_uri'] ?? '')),
+ 'orders_fetch_enabled' => (int) ($row['orders_fetch_enabled'] ?? 0) === 1,
+ 'orders_fetch_start_date' => $this->normalizeDateOrNull((string) ($row['orders_fetch_start_date'] ?? '')),
+ 'is_connected' => trim((string) ($row['refresh_token_encrypted'] ?? '')) !== '',
+ 'token_expires_at' => trim((string) ($row['token_expires_at'] ?? '')),
+ 'connected_at' => trim((string) ($row['connected_at'] ?? '')),
+ ];
+ }
+
+ /**
+ * @param array $payload
+ */
+ public function saveSettings(array $payload): void
+ {
+ $this->ensureRow();
+ $current = $this->fetchRow();
+ if ($current === null) {
+ throw new RuntimeException('Brak rekordu konfiguracji Allegro.');
+ }
+
+ $clientSecret = trim((string) ($payload['client_secret'] ?? ''));
+ $clientSecretEncrypted = trim((string) ($current['client_secret_encrypted'] ?? ''));
+ if ($clientSecret !== '') {
+ $clientSecretEncrypted = (string) $this->encrypt($clientSecret);
+ }
+
+ $statement = $this->pdo->prepare(
+ 'UPDATE allegro_integration_settings
+ SET environment = :environment,
+ client_id = :client_id,
+ client_secret_encrypted = :client_secret_encrypted,
+ redirect_uri = :redirect_uri,
+ orders_fetch_enabled = :orders_fetch_enabled,
+ orders_fetch_start_date = :orders_fetch_start_date,
+ updated_at = NOW()
+ WHERE id = 1'
+ );
+ $statement->execute([
+ 'environment' => $this->normalizeEnvironment((string) ($payload['environment'] ?? 'sandbox')),
+ 'client_id' => $this->nullableString((string) ($payload['client_id'] ?? '')),
+ 'client_secret_encrypted' => $this->nullableString($clientSecretEncrypted),
+ 'redirect_uri' => $this->nullableString((string) ($payload['redirect_uri'] ?? '')),
+ 'orders_fetch_enabled' => ((bool) ($payload['orders_fetch_enabled'] ?? false)) ? 1 : 0,
+ 'orders_fetch_start_date' => $this->nullableString((string) ($payload['orders_fetch_start_date'] ?? '')),
+ ]);
+ }
+
+ /**
+ * @return array|null
+ */
+ public function getOAuthCredentials(): ?array
+ {
+ $row = $this->fetchRow();
+ if ($row === null) {
+ return null;
+ }
+
+ $clientId = trim((string) ($row['client_id'] ?? ''));
+ $clientSecret = $this->decrypt((string) ($row['client_secret_encrypted'] ?? ''));
+ $redirectUri = trim((string) ($row['redirect_uri'] ?? ''));
+ if ($clientId === '' || $clientSecret === '' || $redirectUri === '') {
+ return null;
+ }
+
+ return [
+ 'environment' => $this->normalizeEnvironment((string) ($row['environment'] ?? 'sandbox')),
+ 'client_id' => $clientId,
+ 'client_secret' => $clientSecret,
+ 'redirect_uri' => $redirectUri,
+ ];
+ }
+
+ public function saveTokens(
+ string $accessToken,
+ string $refreshToken,
+ string $tokenType,
+ string $scope,
+ ?string $tokenExpiresAt
+ ): void {
+ $this->ensureRow();
+
+ $statement = $this->pdo->prepare(
+ 'UPDATE allegro_integration_settings
+ SET access_token_encrypted = :access_token_encrypted,
+ refresh_token_encrypted = :refresh_token_encrypted,
+ token_type = :token_type,
+ token_scope = :token_scope,
+ token_expires_at = :token_expires_at,
+ connected_at = NOW(),
+ updated_at = NOW()
+ WHERE id = 1'
+ );
+ $statement->execute([
+ 'access_token_encrypted' => $this->encrypt($accessToken),
+ 'refresh_token_encrypted' => $this->encrypt($refreshToken),
+ 'token_type' => $this->nullableString($tokenType),
+ 'token_scope' => $this->nullableString($scope),
+ 'token_expires_at' => $this->nullableString((string) $tokenExpiresAt),
+ ]);
+ }
+
+ /**
+ * @return array|null
+ */
+ public function getRefreshTokenCredentials(): ?array
+ {
+ $row = $this->fetchRow();
+ if ($row === null) {
+ return null;
+ }
+
+ $clientId = trim((string) ($row['client_id'] ?? ''));
+ $clientSecret = $this->decrypt((string) ($row['client_secret_encrypted'] ?? ''));
+ $refreshToken = $this->decrypt((string) ($row['refresh_token_encrypted'] ?? ''));
+ if ($clientId === '' || $clientSecret === '' || $refreshToken === '') {
+ return null;
+ }
+
+ return [
+ 'environment' => $this->normalizeEnvironment((string) ($row['environment'] ?? 'sandbox')),
+ 'client_id' => $clientId,
+ 'client_secret' => $clientSecret,
+ 'refresh_token' => $refreshToken,
+ ];
+ }
+
+ /**
+ * @return array|null
+ */
+ public function getTokenCredentials(): ?array
+ {
+ $row = $this->fetchRow();
+ if ($row === null) {
+ return null;
+ }
+
+ $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'] ?? ''));
+ if ($clientId === '' || $clientSecret === '' || $refreshToken === '') {
+ return null;
+ }
+
+ return [
+ 'environment' => $this->normalizeEnvironment((string) ($row['environment'] ?? 'sandbox')),
+ 'client_id' => $clientId,
+ 'client_secret' => $clientSecret,
+ 'refresh_token' => $refreshToken,
+ 'access_token' => $accessToken,
+ 'token_expires_at' => trim((string) ($row['token_expires_at'] ?? '')),
+ ];
+ }
+
+ private function ensureRow(): void
+ {
+ $statement = $this->pdo->prepare(
+ 'INSERT INTO allegro_integration_settings (
+ id, environment, orders_fetch_enabled, created_at, updated_at
+ ) VALUES (
+ 1, :environment, 0, NOW(), NOW()
+ )
+ ON DUPLICATE KEY UPDATE
+ updated_at = VALUES(updated_at)'
+ );
+ $statement->execute([
+ 'environment' => 'sandbox',
+ ]);
+ }
+
+ /**
+ * @return array|null
+ */
+ private function fetchRow(): ?array
+ {
+ try {
+ $statement = $this->pdo->prepare('SELECT * FROM allegro_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
+ */
+ private function defaultSettings(): array
+ {
+ return [
+ 'environment' => 'sandbox',
+ 'client_id' => '',
+ 'has_client_secret' => false,
+ 'redirect_uri' => '',
+ 'orders_fetch_enabled' => false,
+ 'orders_fetch_start_date' => null,
+ 'is_connected' => false,
+ 'token_expires_at' => '',
+ 'connected_at' => '',
+ ];
+ }
+
+ private function normalizeEnvironment(string $environment): string
+ {
+ $value = trim(strtolower($environment));
+ if ($value === 'production') {
+ return 'production';
+ }
+
+ return 'sandbox';
+ }
+
+ private function normalizeDateOrNull(string $value): ?string
+ {
+ $trimmed = trim($value);
+ if ($trimmed === '') {
+ return null;
+ }
+ if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $trimmed) !== 1) {
+ return null;
+ }
+
+ return $trimmed;
+ }
+
+ 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 $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 : '';
+ }
+}
diff --git a/src/Modules/Settings/AllegroOAuthClient.php b/src/Modules/Settings/AllegroOAuthClient.php
new file mode 100644
index 0000000..da7e102
--- /dev/null
+++ b/src/Modules/Settings/AllegroOAuthClient.php
@@ -0,0 +1,177 @@
+ $scopes
+ */
+ public function buildAuthorizeUrl(
+ string $environment,
+ string $clientId,
+ string $redirectUri,
+ string $state,
+ array $scopes
+ ): string {
+ $scopeValue = trim(implode(' ', array_values(array_filter(
+ $scopes,
+ static fn (mixed $scope): bool => is_string($scope) && trim($scope) !== ''
+ ))));
+
+ $query = http_build_query([
+ 'response_type' => 'code',
+ 'client_id' => $clientId,
+ 'redirect_uri' => $redirectUri,
+ 'scope' => $scopeValue,
+ 'state' => $state,
+ ]);
+
+ return $this->authorizeBaseUrl($environment) . '?' . $query;
+ }
+
+ /**
+ * @return array{access_token:string, refresh_token:string, token_type:string, scope:string, expires_in:int}
+ */
+ public function exchangeAuthorizationCode(
+ string $environment,
+ string $clientId,
+ string $clientSecret,
+ string $redirectUri,
+ string $authorizationCode
+ ): array {
+ $payload = $this->requestToken(
+ $this->tokenUrl($environment),
+ $clientId,
+ $clientSecret,
+ [
+ 'grant_type' => 'authorization_code',
+ 'code' => $authorizationCode,
+ 'redirect_uri' => $redirectUri,
+ ]
+ );
+
+ $accessToken = trim((string) ($payload['access_token'] ?? ''));
+ $refreshToken = trim((string) ($payload['refresh_token'] ?? ''));
+ if ($accessToken === '' || $refreshToken === '') {
+ throw new RuntimeException('Allegro nie zwrocilo kompletu tokenow OAuth.');
+ }
+
+ return [
+ 'access_token' => $accessToken,
+ 'refresh_token' => $refreshToken,
+ 'token_type' => trim((string) ($payload['token_type'] ?? 'Bearer')),
+ 'scope' => trim((string) ($payload['scope'] ?? '')),
+ 'expires_in' => max(0, (int) ($payload['expires_in'] ?? 0)),
+ ];
+ }
+
+ /**
+ * @return array{access_token:string, refresh_token:string, token_type:string, scope:string, expires_in:int}
+ */
+ public function refreshAccessToken(
+ string $environment,
+ string $clientId,
+ string $clientSecret,
+ string $refreshToken
+ ): array {
+ $payload = $this->requestToken(
+ $this->tokenUrl($environment),
+ $clientId,
+ $clientSecret,
+ [
+ 'grant_type' => 'refresh_token',
+ 'refresh_token' => $refreshToken,
+ ]
+ );
+
+ $accessToken = trim((string) ($payload['access_token'] ?? ''));
+ if ($accessToken === '') {
+ throw new RuntimeException('Allegro nie zwrocilo access_token po odswiezeniu.');
+ }
+
+ return [
+ 'access_token' => $accessToken,
+ 'refresh_token' => trim((string) ($payload['refresh_token'] ?? '')),
+ 'token_type' => trim((string) ($payload['token_type'] ?? 'Bearer')),
+ 'scope' => trim((string) ($payload['scope'] ?? '')),
+ 'expires_in' => max(0, (int) ($payload['expires_in'] ?? 0)),
+ ];
+ }
+
+ private function authorizeBaseUrl(string $environment): string
+ {
+ return $this->normalizeEnvironment($environment) === 'production'
+ ? 'https://allegro.pl/auth/oauth/authorize'
+ : 'https://allegro.pl.allegrosandbox.pl/auth/oauth/authorize';
+ }
+
+ private function tokenUrl(string $environment): string
+ {
+ return $this->normalizeEnvironment($environment) === 'production'
+ ? 'https://allegro.pl/auth/oauth/token'
+ : 'https://allegro.pl.allegrosandbox.pl/auth/oauth/token';
+ }
+
+ private function normalizeEnvironment(string $environment): string
+ {
+ return trim(strtolower($environment)) === 'production' ? 'production' : 'sandbox';
+ }
+
+ /**
+ * @param array $formData
+ * @return array
+ */
+ private function requestToken(
+ string $url,
+ string $clientId,
+ string $clientSecret,
+ array $formData
+ ): array {
+ $ch = curl_init($url);
+ if ($ch === false) {
+ throw new RuntimeException('Nie udalo sie zainicjowac polaczenia OAuth z Allegro.');
+ }
+
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_POST => true,
+ CURLOPT_TIMEOUT => 20,
+ CURLOPT_CONNECTTIMEOUT => 10,
+ CURLOPT_HTTPHEADER => [
+ 'Accept: application/json',
+ 'Content-Type: application/x-www-form-urlencoded',
+ 'Authorization: Basic ' . base64_encode($clientId . ':' . $clientSecret),
+ ],
+ CURLOPT_POSTFIELDS => http_build_query($formData),
+ ]);
+
+ $responseBody = curl_exec($ch);
+ $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $curlError = curl_error($ch);
+ $ch = null;
+
+ if ($responseBody === false) {
+ throw new RuntimeException('Blad polaczenia OAuth z Allegro: ' . $curlError);
+ }
+
+ $json = json_decode((string) $responseBody, true);
+ if (!is_array($json)) {
+ throw new RuntimeException('Nieprawidlowy JSON odpowiedzi OAuth Allegro.');
+ }
+
+ if ($httpCode < 200 || $httpCode >= 300) {
+ $error = trim((string) ($json['error'] ?? 'oauth_error'));
+ $description = trim((string) ($json['error_description'] ?? 'Brak szczegolow bledu OAuth.'));
+ throw new RuntimeException('OAuth Allegro [' . $error . ']: ' . $description);
+ }
+
+ return $json;
+ }
+}
diff --git a/src/Modules/Settings/AllegroOrderImportService.php b/src/Modules/Settings/AllegroOrderImportService.php
new file mode 100644
index 0000000..16582db
--- /dev/null
+++ b/src/Modules/Settings/AllegroOrderImportService.php
@@ -0,0 +1,801 @@
+
+ */
+ public function importSingleOrder(string $checkoutFormId): array
+ {
+ $orderId = trim($checkoutFormId);
+ if ($orderId === '') {
+ throw new RuntimeException('Podaj ID zamowienia Allegro.');
+ }
+
+ $oauth = $this->requireOAuthData();
+ [$accessToken, $oauth] = $this->resolveAccessToken($oauth);
+
+ try {
+ $payload = $this->apiClient->getCheckoutForm(
+ (string) ($oauth['environment'] ?? 'sandbox'),
+ $accessToken,
+ $orderId
+ );
+ } catch (RuntimeException $exception) {
+ if (trim($exception->getMessage()) !== 'ALLEGRO_HTTP_401') {
+ throw $exception;
+ }
+
+ [$accessToken, $oauth] = $this->forceRefreshToken($oauth);
+ $payload = $this->apiClient->getCheckoutForm(
+ (string) ($oauth['environment'] ?? 'sandbox'),
+ $accessToken,
+ $orderId
+ );
+ }
+
+ $mapped = $this->mapCheckoutFormPayload(
+ $payload,
+ (string) ($oauth['environment'] ?? 'sandbox'),
+ $accessToken
+ );
+ $saveResult = $this->orders->upsertOrderAggregate(
+ $mapped['order'],
+ $mapped['addresses'],
+ $mapped['items'],
+ $mapped['payments'],
+ $mapped['shipments'],
+ $mapped['notes'],
+ $mapped['status_history']
+ );
+
+ return [
+ 'order_id' => (int) ($saveResult['order_id'] ?? 0),
+ 'created' => !empty($saveResult['created']),
+ 'source_order_id' => (string) ($mapped['order']['source_order_id'] ?? ''),
+ 'image_diagnostics' => (array) ($mapped['image_diagnostics'] ?? []),
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ private function requireOAuthData(): array
+ {
+ $oauth = $this->integrationRepository->getTokenCredentials();
+ if ($oauth === null) {
+ throw new RuntimeException('Brak kompletnych danych OAuth Allegro. Polacz konto ponownie.');
+ }
+
+ return $oauth;
+ }
+
+ /**
+ * @param array $oauth
+ * @return array{0:string, 1:array}
+ */
+ private function resolveAccessToken(array $oauth): array
+ {
+ $tokenExpiresAt = trim((string) ($oauth['token_expires_at'] ?? ''));
+ $accessToken = trim((string) ($oauth['access_token'] ?? ''));
+ if ($accessToken === '') {
+ return $this->forceRefreshToken($oauth);
+ }
+
+ if ($tokenExpiresAt === '') {
+ return [$accessToken, $oauth];
+ }
+
+ try {
+ $expiresAt = new DateTimeImmutable($tokenExpiresAt);
+ } catch (Throwable) {
+ return $this->forceRefreshToken($oauth);
+ }
+
+ if ($expiresAt <= (new DateTimeImmutable('now'))->add(new DateInterval('PT5M'))) {
+ return $this->forceRefreshToken($oauth);
+ }
+
+ return [$accessToken, $oauth];
+ }
+
+ /**
+ * @param array $oauth
+ * @return array{0:string, 1:array}
+ */
+ private function forceRefreshToken(array $oauth): array
+ {
+ $token = $this->oauthClient->refreshAccessToken(
+ (string) ($oauth['environment'] ?? 'sandbox'),
+ (string) ($oauth['client_id'] ?? ''),
+ (string) ($oauth['client_secret'] ?? ''),
+ (string) ($oauth['refresh_token'] ?? '')
+ );
+
+ $expiresAt = null;
+ $expiresIn = max(0, (int) ($token['expires_in'] ?? 0));
+ if ($expiresIn > 0) {
+ $expiresAt = (new DateTimeImmutable('now'))
+ ->add(new DateInterval('PT' . $expiresIn . 'S'))
+ ->format('Y-m-d H:i:s');
+ }
+
+ $refreshToken = trim((string) ($token['refresh_token'] ?? ''));
+ if ($refreshToken === '') {
+ $refreshToken = (string) ($oauth['refresh_token'] ?? '');
+ }
+
+ $this->integrationRepository->saveTokens(
+ (string) ($token['access_token'] ?? ''),
+ $refreshToken,
+ (string) ($token['token_type'] ?? ''),
+ (string) ($token['scope'] ?? ''),
+ $expiresAt
+ );
+
+ $updatedOauth = $this->requireOAuthData();
+ $newAccessToken = trim((string) ($updatedOauth['access_token'] ?? ''));
+ if ($newAccessToken === '') {
+ throw new RuntimeException('Nie udalo sie zapisac odswiezonego tokenu Allegro.');
+ }
+
+ return [$newAccessToken, $updatedOauth];
+ }
+
+ /**
+ * @param array $payload
+ * @return array{
+ * order:array,
+ * addresses:array>,
+ * items:array>,
+ * image_diagnostics:array,
+ * payments:array>,
+ * shipments:array>,
+ * notes:array>,
+ * status_history:array>
+ * }
+ */
+ private function mapCheckoutFormPayload(array $payload, string $environment, string $accessToken): array
+ {
+ $checkoutFormId = trim((string) ($payload['id'] ?? ''));
+ if ($checkoutFormId === '') {
+ throw new RuntimeException('Odpowiedz Allegro nie zawiera ID zamowienia.');
+ }
+
+ $status = trim((string) ($payload['status'] ?? ''));
+ $fulfillmentStatus = trim((string) ($payload['fulfillment']['status'] ?? ''));
+ $rawAllegroStatus = strtolower($fulfillmentStatus !== '' ? $fulfillmentStatus : $status);
+ $mappedOrderproStatus = $this->statusMappings->findMappedOrderproStatusCode($rawAllegroStatus);
+ $externalStatus = $mappedOrderproStatus !== null ? $mappedOrderproStatus : $rawAllegroStatus;
+ $paymentStatusRaw = strtolower(trim((string) ($payload['payment']['status'] ?? '')));
+
+ $totalWithTax = $this->amountToFloat($payload['summary']['totalToPay'] ?? null);
+ $totalPaid = $this->amountToFloat($payload['summary']['paidAmount'] ?? null);
+ if ($totalPaid === null) {
+ $totalPaid = $this->amountToFloat($payload['payment']['paidAmount'] ?? null);
+ }
+ if ($totalPaid === null) {
+ $totalPaid = $this->amountToFloat($payload['payment']['amount'] ?? null);
+ }
+
+ $currency = trim((string) ($payload['summary']['totalToPay']['currency'] ?? ''));
+ if ($currency === '') {
+ $currency = trim((string) ($payload['payment']['amount']['currency'] ?? 'PLN'));
+ }
+ if ($currency === '') {
+ $currency = 'PLN';
+ }
+
+ $buyer = is_array($payload['buyer'] ?? null) ? $payload['buyer'] : [];
+ $delivery = is_array($payload['delivery'] ?? null) ? $payload['delivery'] : [];
+ $invoice = is_array($payload['invoice'] ?? null) ? $payload['invoice'] : [];
+ $payment = is_array($payload['payment'] ?? null) ? $payload['payment'] : [];
+ $lineItems = is_array($payload['lineItems'] ?? null) ? $payload['lineItems'] : [];
+ $deliveryMethod = is_array($delivery['method'] ?? null) ? $delivery['method'] : [];
+ $deliveryMethodId = trim((string) ($deliveryMethod['id'] ?? ''));
+ $deliveryMethodName = trim((string) ($deliveryMethod['name'] ?? ''));
+ $deliveryForm = $deliveryMethodName !== '' ? $deliveryMethodName : $deliveryMethodId;
+ $deliveryTime = is_array($delivery['time'] ?? null) ? $delivery['time'] : [];
+ $dispatchTime = is_array($deliveryTime['dispatch'] ?? null) ? $deliveryTime['dispatch'] : [];
+ $sendDateMin = $this->normalizeDateTime((string) ($dispatchTime['from'] ?? ''));
+ $sendDateMax = $this->normalizeDateTime((string) ($dispatchTime['to'] ?? ''));
+ if ($sendDateMin === null) {
+ $sendDateMin = $this->normalizeDateTime((string) ($deliveryTime['from'] ?? ''));
+ }
+ if ($sendDateMax === null) {
+ $sendDateMax = $this->normalizeDateTime((string) ($deliveryTime['to'] ?? ''));
+ }
+
+ $boughtAt = $this->normalizeDateTime((string) ($payload['boughtAt'] ?? ''));
+ $updatedAt = $this->normalizeDateTime((string) ($payload['updatedAt'] ?? ''));
+ $fetchedAt = date('Y-m-d H:i:s');
+
+ $order = [
+ 'integration_id' => null,
+ 'source' => 'allegro',
+ 'source_order_id' => $checkoutFormId,
+ 'external_order_id' => $checkoutFormId,
+ 'external_platform_id' => trim((string) ($payload['marketplace']['id'] ?? 'allegro-pl')),
+ 'external_platform_account_id' => null,
+ 'external_status_id' => $externalStatus,
+ 'external_payment_type_id' => trim((string) ($payment['type'] ?? '')),
+ 'payment_status' => $this->mapPaymentStatus($paymentStatusRaw),
+ 'external_carrier_id' => $deliveryForm !== '' ? $deliveryForm : null,
+ 'external_carrier_account_id' => $deliveryMethodId !== '' ? $deliveryMethodId : null,
+ 'customer_login' => trim((string) ($buyer['login'] ?? '')),
+ 'is_invoice' => !empty($invoice['required']),
+ 'is_encrypted' => false,
+ 'is_canceled_by_buyer' => in_array($externalStatus, ['cancelled', 'canceled'], true),
+ 'currency' => strtoupper($currency),
+ 'total_without_tax' => null,
+ 'total_with_tax' => $totalWithTax,
+ 'total_paid' => $totalPaid,
+ 'send_date_min' => $sendDateMin,
+ 'send_date_max' => $sendDateMax,
+ 'ordered_at' => $boughtAt,
+ 'source_created_at' => $boughtAt,
+ 'source_updated_at' => $updatedAt,
+ 'preferences_json' => [
+ 'status' => $status,
+ 'fulfillment_status' => $fulfillmentStatus,
+ 'allegro_status_raw' => $rawAllegroStatus,
+ 'payment_status' => $paymentStatusRaw,
+ 'delivery_method_name' => $deliveryMethodName,
+ 'delivery_method_id' => $deliveryMethodId,
+ 'delivery_cost' => $delivery['cost'] ?? null,
+ 'delivery_time' => $deliveryTime,
+ ],
+ 'payload_json' => $payload,
+ 'fetched_at' => $fetchedAt,
+ ];
+
+ $addresses = $this->buildAddresses($buyer, $delivery, $invoice);
+ $itemsResult = $this->buildItems($lineItems, $environment, $accessToken);
+ $items = (array) ($itemsResult['items'] ?? []);
+ $payments = $this->buildPayments($payment, $currency);
+ $shipments = $this->buildShipments($payload, $delivery);
+ $notes = $this->buildNotes($payload);
+ $statusHistory = [[
+ 'from_status_id' => null,
+ 'to_status_id' => $externalStatus !== '' ? $externalStatus : 'unknown',
+ 'changed_at' => $updatedAt !== null ? $updatedAt : $fetchedAt,
+ 'change_source' => 'import',
+ 'comment' => 'Import z Allegro checkout form',
+ 'payload_json' => [
+ 'status' => $status,
+ 'fulfillment_status' => $fulfillmentStatus,
+ 'allegro_status_raw' => $rawAllegroStatus,
+ ],
+ ]];
+
+ return [
+ 'order' => $order,
+ 'addresses' => $addresses,
+ 'items' => $items,
+ 'image_diagnostics' => (array) ($itemsResult['image_diagnostics'] ?? []),
+ 'payments' => $payments,
+ 'shipments' => $shipments,
+ 'notes' => $notes,
+ 'status_history' => $statusHistory,
+ ];
+ }
+
+ /**
+ * @param array $buyer
+ * @param array $delivery
+ * @param array $invoice
+ * @return array>
+ */
+ private function buildAddresses(array $buyer, array $delivery, array $invoice): array
+ {
+ $result = [];
+
+ $customerName = trim((string) (($buyer['firstName'] ?? '') . ' ' . ($buyer['lastName'] ?? '')));
+ if ($customerName === '') {
+ $customerName = trim((string) ($buyer['login'] ?? ''));
+ }
+ if ($customerName === '') {
+ $customerName = 'Kupujacy Allegro';
+ }
+
+ $result[] = [
+ 'address_type' => 'customer',
+ 'name' => $customerName,
+ 'phone' => $this->nullableString((string) ($buyer['phoneNumber'] ?? '')),
+ 'email' => $this->nullableString((string) ($buyer['email'] ?? '')),
+ 'street_name' => null,
+ 'street_number' => null,
+ 'city' => null,
+ 'zip_code' => null,
+ 'country' => null,
+ 'department' => null,
+ 'parcel_external_id' => null,
+ 'parcel_name' => null,
+ 'address_class' => null,
+ 'company_tax_number' => null,
+ 'company_name' => null,
+ 'payload_json' => $buyer,
+ ];
+
+ $deliveryAddress = is_array($delivery['address'] ?? null) ? $delivery['address'] : [];
+ $pickupPoint = is_array($delivery['pickupPoint'] ?? null) ? $delivery['pickupPoint'] : [];
+ $pickupAddress = is_array($pickupPoint['address'] ?? null) ? $pickupPoint['address'] : [];
+ if ($deliveryAddress !== [] || $pickupAddress !== []) {
+ $isPickupPointDelivery = $pickupAddress !== [];
+ $name = $isPickupPointDelivery
+ ? $this->nullableString((string) ($pickupPoint['name'] ?? ''))
+ : $this->fallbackName($deliveryAddress, 'Dostawa');
+ if ($name === null) {
+ $name = 'Dostawa';
+ }
+
+ $street = $isPickupPointDelivery
+ ? $this->nullableString((string) ($pickupAddress['street'] ?? ''))
+ : $this->nullableString((string) ($deliveryAddress['street'] ?? ''));
+ $city = $isPickupPointDelivery
+ ? $this->nullableString((string) ($pickupAddress['city'] ?? ''))
+ : $this->nullableString((string) ($deliveryAddress['city'] ?? ''));
+ $zipCode = $isPickupPointDelivery
+ ? $this->nullableString((string) ($pickupAddress['zipCode'] ?? ''))
+ : $this->nullableString((string) ($deliveryAddress['zipCode'] ?? ''));
+ $country = $isPickupPointDelivery
+ ? $this->nullableString((string) ($pickupAddress['countryCode'] ?? ''))
+ : $this->nullableString((string) ($deliveryAddress['countryCode'] ?? ''));
+
+ $result[] = [
+ 'address_type' => 'delivery',
+ 'name' => $name,
+ 'phone' => $this->nullableString((string) ($deliveryAddress['phoneNumber'] ?? '')),
+ 'email' => $this->nullableString((string) ($deliveryAddress['email'] ?? $buyer['email'] ?? '')),
+ 'street_name' => $street,
+ 'street_number' => null,
+ 'city' => $city,
+ 'zip_code' => $zipCode,
+ 'country' => $country,
+ 'department' => null,
+ 'parcel_external_id' => $this->nullableString((string) ($pickupPoint['id'] ?? '')),
+ 'parcel_name' => $this->nullableString((string) ($pickupPoint['name'] ?? '')),
+ 'address_class' => null,
+ 'company_tax_number' => null,
+ 'company_name' => $this->nullableString((string) ($deliveryAddress['companyName'] ?? '')),
+ 'payload_json' => [
+ 'address' => $deliveryAddress,
+ 'pickup_point' => $pickupPoint,
+ ],
+ ];
+ }
+
+ $invoiceAddress = is_array($invoice['address'] ?? null) ? $invoice['address'] : [];
+ if ($invoiceAddress !== []) {
+ $result[] = [
+ 'address_type' => 'invoice',
+ 'name' => $this->fallbackName($invoiceAddress, 'Faktura'),
+ 'phone' => $this->nullableString((string) ($invoiceAddress['phoneNumber'] ?? '')),
+ 'email' => $this->nullableString((string) ($invoiceAddress['email'] ?? '')),
+ 'street_name' => $this->nullableString((string) ($invoiceAddress['street'] ?? '')),
+ 'street_number' => null,
+ 'city' => $this->nullableString((string) ($invoiceAddress['city'] ?? '')),
+ 'zip_code' => $this->nullableString((string) ($invoiceAddress['zipCode'] ?? '')),
+ 'country' => $this->nullableString((string) ($invoiceAddress['countryCode'] ?? '')),
+ 'department' => null,
+ 'parcel_external_id' => null,
+ 'parcel_name' => null,
+ 'address_class' => null,
+ 'company_tax_number' => $this->nullableString((string) ($invoiceAddress['taxId'] ?? '')),
+ 'company_name' => $this->nullableString((string) ($invoiceAddress['companyName'] ?? '')),
+ 'payload_json' => $invoiceAddress,
+ ];
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param array $lineItems
+ * @return array{
+ * items:array>,
+ * image_diagnostics:array
+ * }
+ */
+ private function buildItems(array $lineItems, string $environment, string $accessToken): array
+ {
+ $result = [];
+ $offerImageCache = [];
+ $diagnostics = [
+ 'total_items' => 0,
+ 'with_image' => 0,
+ 'without_image' => 0,
+ 'source_counts' => [
+ 'checkout_form' => 0,
+ 'offer_api' => 0,
+ ],
+ 'reason_counts' => [],
+ 'sample_issues' => [],
+ ];
+ $sortOrder = 0;
+ foreach ($lineItems as $itemRaw) {
+ if (!is_array($itemRaw)) {
+ continue;
+ }
+
+ $diagnostics['total_items'] = (int) $diagnostics['total_items'] + 1;
+ $offer = is_array($itemRaw['offer'] ?? null) ? $itemRaw['offer'] : [];
+ $name = trim((string) ($offer['name'] ?? ''));
+ if ($name === '') {
+ $name = 'Pozycja Allegro';
+ }
+
+ $offerId = trim((string) ($offer['id'] ?? ''));
+ $mediaUrl = $this->extractLineItemImageUrl($itemRaw);
+ $imageSource = 'none';
+ $missingReason = null;
+ if ($mediaUrl === null && $offerId !== '') {
+ $offerImageResult = $this->resolveOfferImageUrlFromApi($offerId, $environment, $accessToken, $offerImageCache);
+ $mediaUrl = $offerImageResult['url'];
+ if ($mediaUrl !== null) {
+ $imageSource = 'offer_api';
+ } else {
+ $missingReason = $offerImageResult['reason'];
+ }
+ } elseif ($mediaUrl === null) {
+ $missingReason = 'missing_offer_id';
+ } else {
+ $imageSource = 'checkout_form';
+ }
+
+ if ($mediaUrl !== null) {
+ $diagnostics['with_image'] = (int) $diagnostics['with_image'] + 1;
+ if ($imageSource === 'offer_api') {
+ $diagnostics['source_counts']['offer_api'] = (int) ($diagnostics['source_counts']['offer_api'] ?? 0) + 1;
+ } else {
+ $diagnostics['source_counts']['checkout_form'] = (int) ($diagnostics['source_counts']['checkout_form'] ?? 0) + 1;
+ }
+ } else {
+ $diagnostics['without_image'] = (int) $diagnostics['without_image'] + 1;
+ $reasonCode = $missingReason ?? 'missing_in_checkout_form';
+ $reasonCounts = is_array($diagnostics['reason_counts']) ? $diagnostics['reason_counts'] : [];
+ $reasonCounts[$reasonCode] = (int) ($reasonCounts[$reasonCode] ?? 0) + 1;
+ $diagnostics['reason_counts'] = $reasonCounts;
+
+ $sampleIssues = is_array($diagnostics['sample_issues']) ? $diagnostics['sample_issues'] : [];
+ if (count($sampleIssues) < 5) {
+ $sampleIssues[] = [
+ 'offer_id' => $offerId,
+ 'name' => $name,
+ 'reason' => $reasonCode,
+ ];
+ }
+ $diagnostics['sample_issues'] = $sampleIssues;
+ }
+
+ $result[] = [
+ 'source_item_id' => $this->nullableString((string) ($itemRaw['id'] ?? '')),
+ 'external_item_id' => $this->nullableString((string) ($offer['id'] ?? '')),
+ 'ean' => null,
+ 'sku' => null,
+ 'original_name' => $name,
+ 'original_code' => $this->nullableString((string) ($offer['id'] ?? '')),
+ 'original_price_with_tax' => $this->amountToFloat($itemRaw['originalPrice'] ?? null),
+ 'original_price_without_tax' => null,
+ 'media_url' => $mediaUrl,
+ 'quantity' => (float) ($itemRaw['quantity'] ?? 1),
+ 'tax_rate' => null,
+ 'item_status' => null,
+ 'unit' => 'pcs',
+ 'item_type' => 'product',
+ 'source_product_id' => $this->nullableString((string) ($offer['id'] ?? '')),
+ 'source_product_set_id' => null,
+ 'sort_order' => $sortOrder++,
+ 'payload_json' => $itemRaw,
+ ];
+ }
+
+ return [
+ 'items' => $result,
+ 'image_diagnostics' => $diagnostics,
+ ];
+ }
+
+ /**
+ * @param array $offerImageCache
+ * @return array{url:?string, reason:?string}
+ */
+ private function resolveOfferImageUrlFromApi(
+ string $offerId,
+ string $environment,
+ string $accessToken,
+ array &$offerImageCache
+ ): array {
+ if (array_key_exists($offerId, $offerImageCache)) {
+ return $offerImageCache[$offerId];
+ }
+
+ try {
+ $offerPayload = $this->apiClient->getProductOffer($environment, $accessToken, $offerId);
+ $url = $this->extractOfferImageUrl($offerPayload);
+ if ($url !== null) {
+ $offerImageCache[$offerId] = ['url' => $url, 'reason' => null];
+ return $offerImageCache[$offerId];
+ }
+ $offerImageCache[$offerId] = ['url' => null, 'reason' => 'missing_in_offer_api'];
+ } catch (Throwable $exception) {
+ $reason = $this->mapOfferApiErrorToReason($exception->getMessage());
+ $offerImageCache[$offerId] = ['url' => null, 'reason' => $reason];
+ }
+
+ return $offerImageCache[$offerId];
+ }
+
+ private function mapOfferApiErrorToReason(string $message): string
+ {
+ $normalized = strtoupper(trim($message));
+ if (str_contains($normalized, 'HTTP 403')) {
+ return 'offer_api_access_denied_403';
+ }
+ if (str_contains($normalized, 'HTTP 401')) {
+ return 'offer_api_unauthorized_401';
+ }
+ if (str_contains($normalized, 'HTTP 404')) {
+ return 'offer_api_not_found_404';
+ }
+ if (preg_match('/HTTP\s+(\d{3})/', $normalized, $matches) === 1) {
+ return 'offer_api_http_' . $matches[1];
+ }
+
+ return 'offer_api_request_failed';
+ }
+
+ /**
+ * @param array $offerPayload
+ */
+ private function extractOfferImageUrl(array $offerPayload): ?string
+ {
+ $candidates = [
+ (string) ($offerPayload['imageUrl'] ?? ''),
+ (string) ($offerPayload['image']['url'] ?? ''),
+ ];
+
+ $images = $offerPayload['images'] ?? null;
+ if (is_array($images)) {
+ $firstImage = $images[0] ?? null;
+ if (is_array($firstImage)) {
+ $candidates[] = (string) ($firstImage['url'] ?? '');
+ } elseif (is_string($firstImage)) {
+ $candidates[] = $firstImage;
+ }
+ }
+
+ $productSet = $offerPayload['productSet'] ?? null;
+ if (is_array($productSet)) {
+ $firstSet = $productSet[0] ?? null;
+ if (is_array($firstSet)) {
+ $product = is_array($firstSet['product'] ?? null) ? $firstSet['product'] : [];
+ $productImage = is_array($product['images'] ?? null) ? ($product['images'][0] ?? null) : null;
+ if (is_array($productImage)) {
+ $candidates[] = (string) ($productImage['url'] ?? '');
+ } elseif (is_string($productImage)) {
+ $candidates[] = $productImage;
+ }
+ }
+ }
+
+ foreach ($candidates as $candidate) {
+ $url = trim($candidate);
+ if ($url === '') {
+ continue;
+ }
+ if (str_starts_with($url, '//')) {
+ return 'https:' . $url;
+ }
+ if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
+ continue;
+ }
+ return $url;
+ }
+
+ return null;
+ }
+
+ /**
+ * @param array $itemRaw
+ */
+ private function extractLineItemImageUrl(array $itemRaw): ?string
+ {
+ $offer = is_array($itemRaw['offer'] ?? null) ? $itemRaw['offer'] : [];
+
+ $candidates = [
+ (string) ($itemRaw['imageUrl'] ?? ''),
+ (string) ($offer['imageUrl'] ?? ''),
+ (string) ($offer['image']['url'] ?? ''),
+ ];
+
+ $images = $offer['images'] ?? null;
+ if (is_array($images)) {
+ $firstImage = $images[0] ?? null;
+ if (is_array($firstImage)) {
+ $candidates[] = (string) ($firstImage['url'] ?? '');
+ } elseif (is_string($firstImage)) {
+ $candidates[] = $firstImage;
+ }
+ }
+
+ foreach ($candidates as $candidate) {
+ $url = trim($candidate);
+ if ($url === '') {
+ continue;
+ }
+ if (str_starts_with($url, '//')) {
+ return 'https:' . $url;
+ }
+ if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
+ continue;
+ }
+ return $url;
+ }
+
+ return null;
+ }
+
+ /**
+ * @param array $payment
+ * @return array>
+ */
+ private function buildPayments(array $payment, string $fallbackCurrency): array
+ {
+ $paymentId = trim((string) ($payment['id'] ?? ''));
+ if ($paymentId === '') {
+ return [];
+ }
+
+ $amount = $this->amountToFloat($payment['paidAmount'] ?? null);
+ if ($amount === null) {
+ $amount = $this->amountToFloat($payment['amount'] ?? null);
+ }
+
+ return [[
+ 'source_payment_id' => $paymentId,
+ 'external_payment_id' => $paymentId,
+ 'payment_type_id' => trim((string) ($payment['type'] ?? 'allegro')),
+ 'payment_date' => $this->normalizeDateTime((string) ($payment['finishedAt'] ?? '')),
+ 'amount' => $amount,
+ 'currency' => $this->nullableString((string) ($payment['amount']['currency'] ?? $fallbackCurrency)),
+ 'comment' => $this->nullableString((string) ($payment['provider'] ?? '')),
+ 'payload_json' => $payment,
+ ]];
+ }
+
+ /**
+ * @param array $payload
+ * @param array $delivery
+ * @return array>
+ */
+ private function buildShipments(array $payload, array $delivery): array
+ {
+ $shipments = is_array($payload['fulfillment']['shipments'] ?? null)
+ ? $payload['fulfillment']['shipments']
+ : [];
+
+ $result = [];
+ foreach ($shipments as $shipmentRaw) {
+ if (!is_array($shipmentRaw)) {
+ continue;
+ }
+ $trackingNumber = trim((string) ($shipmentRaw['waybill'] ?? $shipmentRaw['trackingNumber'] ?? ''));
+ if ($trackingNumber === '') {
+ continue;
+ }
+
+ $carrierId = trim((string) ($shipmentRaw['carrierId'] ?? $delivery['method']['id'] ?? 'allegro'));
+ $result[] = [
+ 'source_shipment_id' => $this->nullableString((string) ($shipmentRaw['id'] ?? '')),
+ 'external_shipment_id' => $this->nullableString((string) ($shipmentRaw['id'] ?? '')),
+ 'tracking_number' => $trackingNumber,
+ 'carrier_provider_id' => $carrierId !== '' ? $carrierId : 'allegro',
+ 'posted_at' => $this->normalizeDateTime((string) ($shipmentRaw['createdAt'] ?? $payload['updatedAt'] ?? '')),
+ 'media_uuid' => null,
+ 'payload_json' => $shipmentRaw,
+ ];
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param array $payload
+ * @return array>
+ */
+ private function buildNotes(array $payload): array
+ {
+ $message = trim((string) ($payload['messageToSeller'] ?? ''));
+ if ($message === '') {
+ return [];
+ }
+
+ return [[
+ 'source_note_id' => null,
+ 'note_type' => 'buyer_message',
+ 'created_at_external' => $this->normalizeDateTime((string) ($payload['updatedAt'] ?? '')),
+ 'comment' => $message,
+ 'payload_json' => ['messageToSeller' => $message],
+ ]];
+ }
+
+ private function mapPaymentStatus(string $status): ?int
+ {
+ return match ($status) {
+ 'paid', 'finished', 'completed' => 2,
+ 'partially_paid', 'in_progress' => 1,
+ 'cancelled', 'canceled', 'failed', 'unpaid' => 0,
+ default => null,
+ };
+ }
+
+ private function amountToFloat(mixed $amountNode): ?float
+ {
+ if (!is_array($amountNode)) {
+ return null;
+ }
+ $value = trim((string) ($amountNode['amount'] ?? ''));
+ if ($value === '' || !is_numeric($value)) {
+ return null;
+ }
+
+ return (float) $value;
+ }
+
+ 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;
+ }
+ }
+
+ /**
+ * @param array $address
+ */
+ private function fallbackName(array $address, string $fallback): string
+ {
+ $name = trim((string) (($address['firstName'] ?? '') . ' ' . ($address['lastName'] ?? '')));
+ if ($name !== '') {
+ return $name;
+ }
+
+ $company = trim((string) ($address['companyName'] ?? ''));
+ if ($company !== '') {
+ return $company;
+ }
+
+ return $fallback;
+ }
+
+ private function nullableString(string $value): ?string
+ {
+ $trimmed = trim($value);
+ return $trimmed === '' ? null : $trimmed;
+ }
+}
diff --git a/src/Modules/Settings/AllegroOrderSyncStateRepository.php b/src/Modules/Settings/AllegroOrderSyncStateRepository.php
new file mode 100644
index 0000000..13b1329
--- /dev/null
+++ b/src/Modules/Settings/AllegroOrderSyncStateRepository.php
@@ -0,0 +1,275 @@
+defaultState();
+ if ($integrationId <= 0) {
+ return $default;
+ }
+
+ $columns = $this->resolveColumns();
+ if (!$columns['has_table']) {
+ return $default;
+ }
+
+ $updatedAtColumn = $columns['updated_at_column'];
+ $sourceOrderIdColumn = $columns['source_order_id_column'];
+ if ($updatedAtColumn === null || $sourceOrderIdColumn === null) {
+ return $default;
+ }
+
+ $selectParts = [
+ $updatedAtColumn . ' AS last_synced_updated_at',
+ $sourceOrderIdColumn . ' AS last_synced_source_order_id',
+ 'last_run_at',
+ $columns['has_last_success_at'] ? 'last_success_at' : 'NULL AS last_success_at',
+ 'last_error',
+ ];
+
+ try {
+ $statement = $this->pdo->prepare(
+ 'SELECT ' . implode(', ', $selectParts) . '
+ FROM integration_order_sync_state
+ WHERE integration_id = :integration_id
+ LIMIT 1'
+ );
+ $statement->execute(['integration_id' => $integrationId]);
+ $row = $statement->fetch(PDO::FETCH_ASSOC);
+ } catch (Throwable) {
+ return $default;
+ }
+
+ if (!is_array($row)) {
+ return $default;
+ }
+
+ return [
+ 'last_synced_updated_at' => $this->nullableString((string) ($row['last_synced_updated_at'] ?? '')),
+ 'last_synced_source_order_id' => $this->nullableString((string) ($row['last_synced_source_order_id'] ?? '')),
+ 'last_run_at' => $this->nullableString((string) ($row['last_run_at'] ?? '')),
+ 'last_success_at' => $this->nullableString((string) ($row['last_success_at'] ?? '')),
+ 'last_error' => $this->nullableString((string) ($row['last_error'] ?? '')),
+ ];
+ }
+
+ public function markRunStarted(int $integrationId, DateTimeImmutable $now): void
+ {
+ $this->upsertState($integrationId, [
+ 'last_run_at' => $now->format('Y-m-d H:i:s'),
+ ]);
+ }
+
+ public function markRunFailed(int $integrationId, DateTimeImmutable $now, string $error): void
+ {
+ $this->upsertState($integrationId, [
+ 'last_run_at' => $now->format('Y-m-d H:i:s'),
+ 'last_error' => mb_substr(trim($error), 0, 500),
+ ]);
+ }
+
+ public function markRunSuccess(
+ int $integrationId,
+ DateTimeImmutable $now,
+ ?string $lastSyncedUpdatedAt,
+ ?string $lastSyncedSourceOrderId
+ ): void {
+ $changes = [
+ 'last_run_at' => $now->format('Y-m-d H:i:s'),
+ 'last_error' => null,
+ ];
+
+ if ($lastSyncedUpdatedAt !== null) {
+ $changes['last_synced_updated_at'] = $lastSyncedUpdatedAt;
+ }
+ if ($lastSyncedSourceOrderId !== null) {
+ $changes['last_synced_source_order_id'] = $lastSyncedSourceOrderId;
+ }
+
+ $this->upsertState($integrationId, $changes, true);
+ }
+
+ /**
+ * @param array $changes
+ */
+ private function upsertState(int $integrationId, array $changes, bool $setSuccessAt = false): void
+ {
+ if ($integrationId <= 0) {
+ return;
+ }
+
+ $columns = $this->resolveColumns();
+ if (!$columns['has_table']) {
+ return;
+ }
+
+ $updatedAtColumn = $columns['updated_at_column'];
+ $sourceOrderIdColumn = $columns['source_order_id_column'];
+ if ($updatedAtColumn === null || $sourceOrderIdColumn === null) {
+ return;
+ }
+
+ $insertColumns = ['integration_id', 'created_at', 'updated_at'];
+ $insertValues = [':integration_id', ':created_at', ':updated_at'];
+ $updateParts = ['updated_at = VALUES(updated_at)'];
+ $params = [
+ 'integration_id' => $integrationId,
+ 'created_at' => date('Y-m-d H:i:s'),
+ 'updated_at' => date('Y-m-d H:i:s'),
+ ];
+
+ $columnMap = [
+ 'last_run_at' => 'last_run_at',
+ 'last_error' => 'last_error',
+ 'last_synced_updated_at' => $updatedAtColumn,
+ 'last_synced_source_order_id' => $sourceOrderIdColumn,
+ ];
+
+ foreach ($columnMap as $inputKey => $columnName) {
+ if (!array_key_exists($inputKey, $changes)) {
+ continue;
+ }
+
+ $paramName = $inputKey;
+ $insertColumns[] = $columnName;
+ $insertValues[] = ':' . $paramName;
+ $updateParts[] = $columnName . ' = VALUES(' . $columnName . ')';
+ $params[$paramName] = $changes[$inputKey];
+ }
+
+ if ($setSuccessAt && $columns['has_last_success_at']) {
+ $insertColumns[] = 'last_success_at';
+ $insertValues[] = ':last_success_at';
+ $updateParts[] = 'last_success_at = VALUES(last_success_at)';
+ $params['last_success_at'] = date('Y-m-d H:i:s');
+ }
+
+ try {
+ $statement = $this->pdo->prepare(
+ 'INSERT INTO integration_order_sync_state (' . implode(', ', $insertColumns) . ')
+ VALUES (' . implode(', ', $insertValues) . ')
+ ON DUPLICATE KEY UPDATE ' . implode(', ', $updateParts)
+ );
+ $statement->execute($params);
+ } catch (Throwable) {
+ return;
+ }
+ }
+
+ /**
+ * @return array{
+ * has_table:bool,
+ * updated_at_column:?string,
+ * source_order_id_column:?string,
+ * has_last_success_at:bool
+ * }
+ */
+ private function resolveColumns(): array
+ {
+ if ($this->columns !== null) {
+ return $this->columns;
+ }
+
+ $result = [
+ 'has_table' => false,
+ 'updated_at_column' => null,
+ 'source_order_id_column' => null,
+ 'has_last_success_at' => false,
+ ];
+
+ try {
+ $statement = $this->pdo->prepare(
+ 'SELECT COLUMN_NAME
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = "integration_order_sync_state"'
+ );
+ $statement->execute();
+ $rows = $statement->fetchAll(PDO::FETCH_COLUMN);
+ } catch (Throwable) {
+ $this->columns = $result;
+ return $result;
+ }
+
+ if (!is_array($rows) || $rows === []) {
+ $this->columns = $result;
+ return $result;
+ }
+
+ $available = [];
+ foreach ($rows as $columnName) {
+ $name = trim((string) $columnName);
+ if ($name === '') {
+ continue;
+ }
+ $available[$name] = true;
+ }
+
+ $result['has_table'] = true;
+ if (isset($available['last_synced_order_updated_at'])) {
+ $result['updated_at_column'] = 'last_synced_order_updated_at';
+ } elseif (isset($available['last_synced_external_updated_at'])) {
+ $result['updated_at_column'] = 'last_synced_external_updated_at';
+ }
+
+ if (isset($available['last_synced_source_order_id'])) {
+ $result['source_order_id_column'] = 'last_synced_source_order_id';
+ } elseif (isset($available['last_synced_external_order_id'])) {
+ $result['source_order_id_column'] = 'last_synced_external_order_id';
+ }
+
+ $result['has_last_success_at'] = isset($available['last_success_at']);
+ $this->columns = $result;
+
+ return $result;
+ }
+
+ /**
+ * @return array{
+ * last_synced_updated_at:?string,
+ * last_synced_source_order_id:?string,
+ * last_run_at:?string,
+ * last_success_at:?string,
+ * last_error:?string
+ * }
+ */
+ private function defaultState(): array
+ {
+ return [
+ 'last_synced_updated_at' => null,
+ 'last_synced_source_order_id' => null,
+ 'last_run_at' => null,
+ 'last_success_at' => null,
+ 'last_error' => null,
+ ];
+ }
+
+ private function nullableString(string $value): ?string
+ {
+ $trimmed = trim($value);
+ return $trimmed === '' ? null : $trimmed;
+ }
+}
diff --git a/src/Modules/Settings/AllegroOrdersSyncService.php b/src/Modules/Settings/AllegroOrdersSyncService.php
new file mode 100644
index 0000000..22808a4
--- /dev/null
+++ b/src/Modules/Settings/AllegroOrdersSyncService.php
@@ -0,0 +1,333 @@
+ $options
+ * @return array
+ */
+ public function sync(array $options = []): array
+ {
+ $settings = $this->integrationRepository->getSettings();
+ if (empty($settings['orders_fetch_enabled'])) {
+ return [
+ 'enabled' => false,
+ 'processed' => 0,
+ 'imported_created' => 0,
+ 'imported_updated' => 0,
+ 'failed' => 0,
+ 'skipped' => 0,
+ 'cursor_before' => null,
+ 'cursor_after' => null,
+ 'errors' => [],
+ ];
+ }
+
+ $now = new DateTimeImmutable('now');
+ $state = $this->syncStateRepository->getState(self::ALLEGRO_INTEGRATION_ID);
+ $this->syncStateRepository->markRunStarted(self::ALLEGRO_INTEGRATION_ID, $now);
+
+ $maxPages = max(1, min(20, (int) ($options['max_pages'] ?? 5)));
+ $pageLimit = max(1, min(100, (int) ($options['page_limit'] ?? 50)));
+ $maxOrders = max(1, min(1000, (int) ($options['max_orders'] ?? 200)));
+
+ $startDateRaw = trim((string) ($settings['orders_fetch_start_date'] ?? ''));
+ $startDate = $this->normalizeStartDate($startDateRaw);
+
+ $cursorUpdatedAt = $this->nullableString((string) ($state['last_synced_updated_at'] ?? ''));
+ $cursorSourceOrderId = $this->nullableString((string) ($state['last_synced_source_order_id'] ?? ''));
+
+ $result = [
+ 'enabled' => true,
+ 'processed' => 0,
+ 'imported_created' => 0,
+ 'imported_updated' => 0,
+ 'failed' => 0,
+ 'skipped' => 0,
+ 'cursor_before' => $cursorUpdatedAt,
+ 'cursor_after' => $cursorUpdatedAt,
+ 'errors' => [],
+ ];
+
+ $latestProcessedUpdatedAt = $cursorUpdatedAt;
+ $latestProcessedSourceOrderId = $cursorSourceOrderId;
+
+ try {
+ $oauth = $this->requireOAuthData();
+ [$accessToken, $oauth] = $this->resolveAccessToken($oauth);
+ $offset = 0;
+ $shouldStop = false;
+
+ for ($page = 0; $page < $maxPages; $page++) {
+ try {
+ $response = $this->apiClient->listCheckoutForms(
+ (string) ($oauth['environment'] ?? 'sandbox'),
+ $accessToken,
+ $pageLimit,
+ $offset
+ );
+ } catch (RuntimeException $exception) {
+ if (trim($exception->getMessage()) !== 'ALLEGRO_HTTP_401') {
+ throw $exception;
+ }
+
+ [$accessToken, $oauth] = $this->forceRefreshToken($oauth);
+ $response = $this->apiClient->listCheckoutForms(
+ (string) ($oauth['environment'] ?? 'sandbox'),
+ $accessToken,
+ $pageLimit,
+ $offset
+ );
+ }
+
+ $forms = is_array($response['checkoutForms'] ?? null) ? $response['checkoutForms'] : [];
+ if ($forms === []) {
+ break;
+ }
+
+ foreach ($forms as $form) {
+ if (!is_array($form)) {
+ continue;
+ }
+
+ $sourceOrderId = trim((string) ($form['id'] ?? ''));
+ $sourceUpdatedAt = $this->normalizeDateTime((string) ($form['updatedAt'] ?? $form['boughtAt'] ?? ''));
+ if ($sourceOrderId === '' || $sourceUpdatedAt === null) {
+ $result['skipped'] = (int) $result['skipped'] + 1;
+ continue;
+ }
+
+ if ($startDate !== null && $sourceUpdatedAt < $startDate) {
+ $shouldStop = true;
+ break;
+ }
+
+ if (!$this->isAfterCursor($sourceUpdatedAt, $sourceOrderId, $cursorUpdatedAt, $cursorSourceOrderId)) {
+ $shouldStop = true;
+ break;
+ }
+
+ if (((int) $result['processed']) >= $maxOrders) {
+ $shouldStop = true;
+ break;
+ }
+
+ $result['processed'] = (int) $result['processed'] + 1;
+
+ try {
+ $importResult = $this->orderImportService->importSingleOrder($sourceOrderId);
+ if (!empty($importResult['created'])) {
+ $result['imported_created'] = (int) $result['imported_created'] + 1;
+ } else {
+ $result['imported_updated'] = (int) $result['imported_updated'] + 1;
+ }
+ } catch (Throwable $exception) {
+ $result['failed'] = (int) $result['failed'] + 1;
+ $errors = is_array($result['errors']) ? $result['errors'] : [];
+ if (count($errors) < 20) {
+ $errors[] = [
+ 'source_order_id' => $sourceOrderId,
+ 'error' => $exception->getMessage(),
+ ];
+ }
+ $result['errors'] = $errors;
+ }
+
+ if ($this->isAfterCursor(
+ $sourceUpdatedAt,
+ $sourceOrderId,
+ $latestProcessedUpdatedAt,
+ $latestProcessedSourceOrderId
+ )) {
+ $latestProcessedUpdatedAt = $sourceUpdatedAt;
+ $latestProcessedSourceOrderId = $sourceOrderId;
+ }
+ }
+
+ if ($shouldStop || count($forms) < $pageLimit) {
+ break;
+ }
+
+ $offset += $pageLimit;
+ }
+
+ $this->syncStateRepository->markRunSuccess(
+ self::ALLEGRO_INTEGRATION_ID,
+ new DateTimeImmutable('now'),
+ $latestProcessedUpdatedAt,
+ $latestProcessedSourceOrderId
+ );
+ $result['cursor_after'] = $latestProcessedUpdatedAt;
+
+ return $result;
+ } catch (Throwable $exception) {
+ $this->syncStateRepository->markRunFailed(
+ self::ALLEGRO_INTEGRATION_ID,
+ new DateTimeImmutable('now'),
+ $exception->getMessage()
+ );
+ throw $exception;
+ }
+ }
+
+ /**
+ * @return array
+ */
+ private function requireOAuthData(): array
+ {
+ $oauth = $this->integrationRepository->getTokenCredentials();
+ if ($oauth === null) {
+ throw new RuntimeException('Brak kompletnych danych OAuth Allegro. Polacz konto ponownie.');
+ }
+
+ return $oauth;
+ }
+
+ /**
+ * @param array $oauth
+ * @return array{0:string, 1:array}
+ */
+ private function resolveAccessToken(array $oauth): array
+ {
+ $tokenExpiresAt = trim((string) ($oauth['token_expires_at'] ?? ''));
+ $accessToken = trim((string) ($oauth['access_token'] ?? ''));
+ if ($accessToken === '') {
+ return $this->forceRefreshToken($oauth);
+ }
+
+ if ($tokenExpiresAt === '') {
+ return [$accessToken, $oauth];
+ }
+
+ try {
+ $expiresAt = new DateTimeImmutable($tokenExpiresAt);
+ } catch (Throwable) {
+ return $this->forceRefreshToken($oauth);
+ }
+
+ if ($expiresAt <= (new DateTimeImmutable('now'))->add(new DateInterval('PT5M'))) {
+ return $this->forceRefreshToken($oauth);
+ }
+
+ return [$accessToken, $oauth];
+ }
+
+ /**
+ * @param array $oauth
+ * @return array{0:string, 1:array}
+ */
+ private function forceRefreshToken(array $oauth): array
+ {
+ $token = $this->oauthClient->refreshAccessToken(
+ (string) ($oauth['environment'] ?? 'sandbox'),
+ (string) ($oauth['client_id'] ?? ''),
+ (string) ($oauth['client_secret'] ?? ''),
+ (string) ($oauth['refresh_token'] ?? '')
+ );
+
+ $expiresAt = null;
+ $expiresIn = max(0, (int) ($token['expires_in'] ?? 0));
+ if ($expiresIn > 0) {
+ $expiresAt = (new DateTimeImmutable('now'))
+ ->add(new DateInterval('PT' . $expiresIn . 'S'))
+ ->format('Y-m-d H:i:s');
+ }
+
+ $refreshToken = trim((string) ($token['refresh_token'] ?? ''));
+ if ($refreshToken === '') {
+ $refreshToken = (string) ($oauth['refresh_token'] ?? '');
+ }
+
+ $this->integrationRepository->saveTokens(
+ (string) ($token['access_token'] ?? ''),
+ $refreshToken,
+ (string) ($token['token_type'] ?? ''),
+ (string) ($token['scope'] ?? ''),
+ $expiresAt
+ );
+
+ $updatedOauth = $this->requireOAuthData();
+ $newAccessToken = trim((string) ($updatedOauth['access_token'] ?? ''));
+ if ($newAccessToken === '') {
+ throw new RuntimeException('Nie udalo sie zapisac odswiezonego tokenu Allegro.');
+ }
+
+ return [$newAccessToken, $updatedOauth];
+ }
+
+ 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 normalizeStartDate(string $value): ?string
+ {
+ $trimmed = trim($value);
+ if ($trimmed === '') {
+ return null;
+ }
+ if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $trimmed) !== 1) {
+ return null;
+ }
+
+ return $trimmed . ' 00:00:00';
+ }
+
+ private function isAfterCursor(
+ string $sourceUpdatedAt,
+ string $sourceOrderId,
+ ?string $cursorUpdatedAt,
+ ?string $cursorSourceOrderId
+ ): bool {
+ if ($cursorUpdatedAt === null || $cursorUpdatedAt === '') {
+ return true;
+ }
+
+ if ($sourceUpdatedAt > $cursorUpdatedAt) {
+ return true;
+ }
+ if ($sourceUpdatedAt < $cursorUpdatedAt) {
+ return false;
+ }
+
+ if ($cursorSourceOrderId === null || $cursorSourceOrderId === '') {
+ return true;
+ }
+
+ return strcmp($sourceOrderId, $cursorSourceOrderId) > 0;
+ }
+
+ private function nullableString(string $value): ?string
+ {
+ $trimmed = trim($value);
+ return $trimmed === '' ? null : $trimmed;
+ }
+}
diff --git a/src/Modules/Settings/AllegroStatusDiscoveryService.php b/src/Modules/Settings/AllegroStatusDiscoveryService.php
new file mode 100644
index 0000000..762b6e9
--- /dev/null
+++ b/src/Modules/Settings/AllegroStatusDiscoveryService.php
@@ -0,0 +1,180 @@
+requireOAuthData();
+ [$accessToken, $oauth] = $this->resolveAccessToken($oauth);
+
+ $unique = [];
+ $safePages = max(1, min(10, $maxPages));
+ $safeLimit = max(1, min(100, $pageLimit));
+ $offset = 0;
+ $samples = 0;
+
+ for ($page = 0; $page < $safePages; $page++) {
+ try {
+ $response = $this->apiClient->listCheckoutForms(
+ (string) ($oauth['environment'] ?? 'sandbox'),
+ $accessToken,
+ $safeLimit,
+ $offset
+ );
+ } catch (RuntimeException $exception) {
+ if (trim($exception->getMessage()) !== 'ALLEGRO_HTTP_401') {
+ throw $exception;
+ }
+ [$accessToken, $oauth] = $this->forceRefreshToken($oauth);
+ $response = $this->apiClient->listCheckoutForms(
+ (string) ($oauth['environment'] ?? 'sandbox'),
+ $accessToken,
+ $safeLimit,
+ $offset
+ );
+ }
+
+ $forms = is_array($response['checkoutForms'] ?? null) ? $response['checkoutForms'] : [];
+ if ($forms === []) {
+ break;
+ }
+
+ foreach ($forms as $form) {
+ if (!is_array($form)) {
+ continue;
+ }
+ $rawStatus = strtolower(trim((string) ($form['fulfillment']['status'] ?? $form['status'] ?? '')));
+ if ($rawStatus === '') {
+ continue;
+ }
+ $samples++;
+ $unique[$rawStatus] = $this->prettifyStatusName($rawStatus);
+ }
+
+ if (count($forms) < $safeLimit) {
+ break;
+ }
+
+ $offset += $safeLimit;
+ }
+
+ foreach ($unique as $code => $name) {
+ $this->statusMappings->upsertDiscoveredStatus((string) $code, (string) $name);
+ }
+
+ return [
+ 'discovered' => count($unique),
+ 'samples' => $samples,
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ private function requireOAuthData(): array
+ {
+ $oauth = $this->integrationRepository->getTokenCredentials();
+ if ($oauth === null) {
+ throw new RuntimeException('Brak kompletnych danych OAuth Allegro. Polacz konto ponownie.');
+ }
+
+ return $oauth;
+ }
+
+ /**
+ * @param array $oauth
+ * @return array{0:string, 1:array}
+ */
+ private function resolveAccessToken(array $oauth): array
+ {
+ $tokenExpiresAt = trim((string) ($oauth['token_expires_at'] ?? ''));
+ $accessToken = trim((string) ($oauth['access_token'] ?? ''));
+ if ($accessToken === '') {
+ return $this->forceRefreshToken($oauth);
+ }
+
+ if ($tokenExpiresAt === '') {
+ return [$accessToken, $oauth];
+ }
+
+ try {
+ $expiresAt = new DateTimeImmutable($tokenExpiresAt);
+ } catch (Throwable) {
+ return $this->forceRefreshToken($oauth);
+ }
+
+ if ($expiresAt <= (new DateTimeImmutable('now'))->add(new DateInterval('PT5M'))) {
+ return $this->forceRefreshToken($oauth);
+ }
+
+ return [$accessToken, $oauth];
+ }
+
+ /**
+ * @param array $oauth
+ * @return array{0:string, 1:array}
+ */
+ private function forceRefreshToken(array $oauth): array
+ {
+ $token = $this->oauthClient->refreshAccessToken(
+ (string) ($oauth['environment'] ?? 'sandbox'),
+ (string) ($oauth['client_id'] ?? ''),
+ (string) ($oauth['client_secret'] ?? ''),
+ (string) ($oauth['refresh_token'] ?? '')
+ );
+
+ $expiresAt = null;
+ $expiresIn = max(0, (int) ($token['expires_in'] ?? 0));
+ if ($expiresIn > 0) {
+ $expiresAt = (new DateTimeImmutable('now'))
+ ->add(new DateInterval('PT' . $expiresIn . 'S'))
+ ->format('Y-m-d H:i:s');
+ }
+
+ $refreshToken = trim((string) ($token['refresh_token'] ?? ''));
+ if ($refreshToken === '') {
+ $refreshToken = (string) ($oauth['refresh_token'] ?? '');
+ }
+
+ $this->integrationRepository->saveTokens(
+ (string) ($token['access_token'] ?? ''),
+ $refreshToken,
+ (string) ($token['token_type'] ?? ''),
+ (string) ($token['scope'] ?? ''),
+ $expiresAt
+ );
+
+ $updatedOauth = $this->requireOAuthData();
+ $newAccessToken = trim((string) ($updatedOauth['access_token'] ?? ''));
+ if ($newAccessToken === '') {
+ throw new RuntimeException('Nie udalo sie zapisac odswiezonego tokenu Allegro.');
+ }
+
+ return [$newAccessToken, $updatedOauth];
+ }
+
+ private function prettifyStatusName(string $statusCode): string
+ {
+ $normalized = str_replace(['_', '-'], ' ', strtolower(trim($statusCode)));
+ return ucfirst($normalized);
+ }
+}
diff --git a/src/Modules/Settings/AllegroStatusMappingRepository.php b/src/Modules/Settings/AllegroStatusMappingRepository.php
new file mode 100644
index 0000000..fcac410
--- /dev/null
+++ b/src/Modules/Settings/AllegroStatusMappingRepository.php
@@ -0,0 +1,131 @@
+>
+ */
+ public function listMappings(): array
+ {
+ $statement = $this->pdo->query(
+ 'SELECT id, allegro_status_code, allegro_status_name, orderpro_status_code, created_at, updated_at
+ FROM allegro_order_status_mappings
+ ORDER BY allegro_status_code ASC'
+ );
+ $rows = $statement->fetchAll(PDO::FETCH_ASSOC);
+ if (!is_array($rows)) {
+ return [];
+ }
+
+ return array_map(static function (array $row): array {
+ return [
+ 'id' => (int) ($row['id'] ?? 0),
+ 'allegro_status_code' => strtolower(trim((string) ($row['allegro_status_code'] ?? ''))),
+ 'allegro_status_name' => trim((string) ($row['allegro_status_name'] ?? '')),
+ 'orderpro_status_code' => strtolower(trim((string) ($row['orderpro_status_code'] ?? ''))),
+ 'created_at' => (string) ($row['created_at'] ?? ''),
+ 'updated_at' => (string) ($row['updated_at'] ?? ''),
+ ];
+ }, $rows);
+ }
+
+ public function upsertMapping(string $allegroStatusCode, ?string $allegroStatusName, ?string $orderproStatusCode): void
+ {
+ $code = strtolower(trim($allegroStatusCode));
+ $orderproCode = $orderproStatusCode !== null ? strtolower(trim($orderproStatusCode)) : null;
+ if ($code === '') {
+ return;
+ }
+
+ $statement = $this->pdo->prepare(
+ 'INSERT INTO allegro_order_status_mappings (
+ allegro_status_code, allegro_status_name, orderpro_status_code, created_at, updated_at
+ ) VALUES (
+ :allegro_status_code, :allegro_status_name, :orderpro_status_code, NOW(), NOW()
+ )
+ ON DUPLICATE KEY UPDATE
+ allegro_status_name = VALUES(allegro_status_name),
+ orderpro_status_code = VALUES(orderpro_status_code),
+ updated_at = VALUES(updated_at)'
+ );
+ $statement->execute([
+ 'allegro_status_code' => $code,
+ 'allegro_status_name' => $this->nullableString((string) $allegroStatusName),
+ 'orderpro_status_code' => $orderproCode !== null && $orderproCode !== '' ? $orderproCode : null,
+ ]);
+ }
+
+ public function upsertDiscoveredStatus(string $allegroStatusCode, ?string $allegroStatusName): void
+ {
+ $code = strtolower(trim($allegroStatusCode));
+ if ($code === '') {
+ return;
+ }
+
+ $statement = $this->pdo->prepare(
+ 'INSERT INTO allegro_order_status_mappings (
+ allegro_status_code, allegro_status_name, orderpro_status_code, created_at, updated_at
+ ) VALUES (
+ :allegro_status_code, :allegro_status_name, NULL, NOW(), NOW()
+ )
+ ON DUPLICATE KEY UPDATE
+ allegro_status_name = CASE
+ WHEN VALUES(allegro_status_name) IS NULL OR VALUES(allegro_status_name) = "" THEN allegro_status_name
+ ELSE VALUES(allegro_status_name)
+ END,
+ updated_at = VALUES(updated_at)'
+ );
+ $statement->execute([
+ 'allegro_status_code' => $code,
+ 'allegro_status_name' => $this->nullableString((string) $allegroStatusName),
+ ]);
+ }
+
+ public function deleteMappingById(int $id): void
+ {
+ if ($id <= 0) {
+ return;
+ }
+
+ $statement = $this->pdo->prepare('DELETE FROM allegro_order_status_mappings WHERE id = :id');
+ $statement->execute(['id' => $id]);
+ }
+
+ public function findMappedOrderproStatusCode(string $allegroStatusCode): ?string
+ {
+ $code = strtolower(trim($allegroStatusCode));
+ if ($code === '') {
+ return null;
+ }
+
+ $statement = $this->pdo->prepare(
+ 'SELECT orderpro_status_code
+ FROM allegro_order_status_mappings
+ WHERE allegro_status_code = :allegro_status_code
+ LIMIT 1'
+ );
+ $statement->execute(['allegro_status_code' => $code]);
+ $value = $statement->fetchColumn();
+ if (!is_string($value)) {
+ return null;
+ }
+
+ $mapped = strtolower(trim($value));
+ return $mapped !== '' ? $mapped : null;
+ }
+
+ private function nullableString(string $value): ?string
+ {
+ $trimmed = trim($value);
+ return $trimmed === '' ? null : $trimmed;
+ }
+}
diff --git a/src/Modules/Settings/AllegroStatusSyncService.php b/src/Modules/Settings/AllegroStatusSyncService.php
new file mode 100644
index 0000000..5189d47
--- /dev/null
+++ b/src/Modules/Settings/AllegroStatusSyncService.php
@@ -0,0 +1,53 @@
+
+ */
+ public function sync(): array
+ {
+ $direction = trim($this->cronRepository->getStringSetting(
+ 'allegro_status_sync_direction',
+ self::DIRECTION_ALLEGRO_TO_ORDERPRO
+ ));
+ if (!in_array($direction, [self::DIRECTION_ALLEGRO_TO_ORDERPRO, self::DIRECTION_ORDERPRO_TO_ALLEGRO], true)) {
+ $direction = self::DIRECTION_ALLEGRO_TO_ORDERPRO;
+ }
+
+ if ($direction === self::DIRECTION_ORDERPRO_TO_ALLEGRO) {
+ return [
+ 'ok' => true,
+ 'direction' => $direction,
+ 'processed' => 0,
+ 'message' => 'Kierunek orderPRO -> Allegro nie jest jeszcze wdrozony.',
+ ];
+ }
+
+ $ordersResult = $this->ordersSyncService->sync([
+ 'max_pages' => 3,
+ 'page_limit' => 50,
+ 'max_orders' => 100,
+ ]);
+
+ return [
+ 'ok' => true,
+ 'direction' => $direction,
+ 'orders_sync' => $ordersResult,
+ ];
+ }
+}
diff --git a/src/Modules/Settings/CronSettingsController.php b/src/Modules/Settings/CronSettingsController.php
new file mode 100644
index 0000000..4411a1a
--- /dev/null
+++ b/src/Modules/Settings/CronSettingsController.php
@@ -0,0 +1,85 @@
+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);
+ } catch (Throwable $exception) {
+ Flash::set('settings_error', $this->translator->get('settings.cron.flash.load_failed') . ' ' . $exception->getMessage());
+ $runOnWeb = $this->runOnWebDefault;
+ $webLimit = $this->webLimitDefault;
+ $schedules = [];
+ $futureJobs = [];
+ $pastJobs = [];
+ }
+
+ $html = $this->template->render('settings/cron', [
+ 'title' => $this->translator->get('settings.cron.title'),
+ 'activeMenu' => 'settings',
+ 'activeSettings' => 'cron',
+ 'user' => $this->auth->user(),
+ 'csrfToken' => Csrf::token(),
+ 'runOnWeb' => $runOnWeb,
+ 'webLimit' => $webLimit,
+ 'schedules' => $schedules,
+ 'futureJobs' => $futureJobs,
+ 'pastJobs' => $pastJobs,
+ 'errorMessage' => (string) Flash::get('settings_error', ''),
+ 'successMessage' => (string) Flash::get('settings_success', ''),
+ ], 'layouts/app');
+
+ return Response::html($html);
+ }
+
+ public function save(Request $request): Response
+ {
+ $csrfToken = (string) $request->input('_token', '');
+ if (!Csrf::validate($csrfToken)) {
+ Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
+ return Response::redirect('/settings/cron');
+ }
+
+ $runOnWeb = (string) $request->input('cron_run_on_web', '0') === '1';
+ $webLimitRaw = (int) $request->input('cron_web_limit', $this->webLimitDefault);
+ $webLimit = max(1, min(100, $webLimitRaw));
+
+ try {
+ $this->cronRepository->upsertSetting('cron_run_on_web', $runOnWeb ? '1' : '0');
+ $this->cronRepository->upsertSetting('cron_web_limit', (string) $webLimit);
+ Flash::set('settings_success', $this->translator->get('settings.cron.flash.saved'));
+ } catch (Throwable $exception) {
+ Flash::set('settings_error', $this->translator->get('settings.cron.flash.save_failed') . ' ' . $exception->getMessage());
+ }
+
+ return Response::redirect('/settings/cron');
+ }
+}