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>
223 lines
7.9 KiB
PHP
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;
|
|
}
|
|
|
|
}
|