Files
orderPRO/src/Modules/Settings/AllegroOrdersSyncService.php
Jacek Pyziak 7ac4293df4 feat: Implement Allegro Order Sync and Status Management
- 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.
2026-03-04 23:21:35 +01:00

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;
}
}