Files
orderPRO/src/Modules/Settings/AllegroOrdersSyncService.php
Jacek Pyziak 3c27c4e54a feat(06-sonarqube-quality): introduce typed exception hierarchy (S112 fix)
Replace 86+ generic RuntimeException throws with domain-specific exception
classes: AllegroApiException, AllegroOAuthException, ApaczkaApiException,
ShipmentException, IntegrationConfigException — all extending OrderProException
extends RuntimeException. Existing catch(RuntimeException) blocks unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 11:04:52 +01:00

223 lines
7.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Support\StringHelper;
use DateTimeImmutable;
use RuntimeException;
use AppCoreExceptionsIntegrationConfigException;
use Throwable;
final class AllegroOrdersSyncService
{
public function __construct(
private readonly AllegroIntegrationRepository $integrationRepository,
private readonly AllegroOrderSyncStateRepository $syncStateRepository,
private readonly AllegroTokenManager $tokenManager,
private readonly AllegroApiClient $apiClient,
private readonly AllegroOrderImportService $orderImportService
) {
}
/**
* @param array<string, mixed> $options
* @return array<string, mixed>
*/
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' => [],
];
}
$integrationId = $this->integrationRepository->getActiveIntegrationId();
if ($integrationId <= 0) {
throw new IntegrationConfigException('Brak aktywnej integracji bazowej Allegro.');
}
$now = new DateTimeImmutable('now');
$state = $this->syncStateRepository->getState($integrationId);
$this->syncStateRepository->markRunStarted($integrationId, $now);
$maxPages = max(1, min(20, (int) ($options['max_pages'] ?? 5)));
$pageLimit = max(1, min(100, (int) ($options['page_limit'] ?? 50)));
$maxOrders = max(1, min(1000, (int) ($options['max_orders'] ?? 200)));
$startDateRaw = trim((string) ($settings['orders_fetch_start_date'] ?? ''));
$startDate = $this->normalizeStartDate($startDateRaw);
$cursorUpdatedAt = StringHelper::nullableString((string) ($state['last_synced_updated_at'] ?? ''));
$cursorSourceOrderId = StringHelper::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 {
[$accessToken, $env] = $this->tokenManager->resolveToken();
$offset = 0;
$shouldStop = false;
for ($page = 0; $page < $maxPages; $page++) {
try {
$response = $this->apiClient->listCheckoutForms($env, $accessToken, $pageLimit, $offset);
} catch (RuntimeException $exception) {
if (trim($exception->getMessage()) !== 'ALLEGRO_HTTP_401') {
throw $exception;
}
[$accessToken, $env] = $this->tokenManager->resolveToken();
$response = $this->apiClient->listCheckoutForms($env, $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 = StringHelper::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(
$integrationId,
new DateTimeImmutable('now'),
$latestProcessedUpdatedAt,
$latestProcessedSourceOrderId
);
$result['cursor_after'] = $latestProcessedUpdatedAt;
return $result;
} catch (Throwable $exception) {
$this->syncStateRepository->markRunFailed(
$integrationId,
new DateTimeImmutable('now'),
$exception->getMessage()
);
throw $exception;
}
}
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;
}
}