- Added AllegroOrderSyncStateRepository for managing sync state with Allegro orders. - Introduced AllegroOrdersSyncService to handle the synchronization of orders from Allegro. - Created AllegroStatusDiscoveryService to discover and store order statuses from Allegro. - Developed AllegroStatusMappingRepository for managing status mappings between Allegro and OrderPro. - Implemented AllegroStatusSyncService to facilitate status synchronization. - Added CronSettingsController for managing cron job settings related to Allegro integration.
334 lines
11 KiB
PHP
334 lines
11 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Settings;
|
|
|
|
use DateInterval;
|
|
use DateTimeImmutable;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
final class AllegroOrdersSyncService
|
|
{
|
|
private const ALLEGRO_INTEGRATION_ID = 1;
|
|
|
|
public function __construct(
|
|
private readonly AllegroIntegrationRepository $integrationRepository,
|
|
private readonly AllegroOrderSyncStateRepository $syncStateRepository,
|
|
private readonly AllegroOAuthClient $oauthClient,
|
|
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' => [],
|
|
];
|
|
}
|
|
|
|
$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<string, string>
|
|
*/
|
|
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<string, string> $oauth
|
|
* @return array{0:string, 1:array<string, string>}
|
|
*/
|
|
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<string, string> $oauth
|
|
* @return array{0:string, 1:array<string, string>}
|
|
*/
|
|
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;
|
|
}
|
|
}
|