Files
orderPRO/src/Modules/Settings/ShopproOrdersSyncService.php
2026-04-07 22:39:16 +02:00

386 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Support\StringHelper;
use App\Modules\Automation\AutomationService;
use App\Modules\Orders\OrderImportRepository;
use App\Modules\Orders\OrdersRepository;
use DateTimeImmutable;
use Throwable;
final class ShopproOrdersSyncService
{
public function __construct(
private readonly ShopproIntegrationsRepository $integrations,
private readonly ShopproOrderSyncStateRepository $syncState,
private readonly ShopproApiClient $apiClient,
private readonly OrderImportRepository $orderImportRepository,
private readonly ShopproStatusMappingRepository $statusMappings,
private readonly OrdersRepository $orders,
private readonly ShopproOrderMapper $mapper,
private readonly ShopproProductImageResolver $imageResolver,
private readonly ?ShopproPullStatusMappingRepository $pullStatusMappings = null,
private readonly ?AutomationService $automationService = null
) {
}
/**
* @param array<string, mixed> $options
* @return array<string, mixed>
*/
public function sync(array $options = []): array
{
$maxPages = max(1, min(20, (int) ($options['max_pages'] ?? 3)));
$pageLimit = max(1, min(100, (int) ($options['page_limit'] ?? 50)));
$maxOrders = max(1, min(1000, (int) ($options['max_orders'] ?? 200)));
$ignoreOrdersFetchEnabled = !empty($options['ignore_orders_fetch_enabled']);
$allowedIntegrationIds = $this->normalizeIntegrationIds($options['allowed_integration_ids'] ?? []);
$result = [
'processed' => 0,
'imported_created' => 0,
'imported_updated' => 0,
'failed' => 0,
'skipped' => 0,
'checked_integrations' => 0,
'errors' => [],
];
foreach ($this->integrations->listIntegrations() as $integration) {
$integrationId = (int) ($integration['id'] ?? 0);
if ($integrationId <= 0) {
continue;
}
if ($allowedIntegrationIds !== [] && !isset($allowedIntegrationIds[$integrationId])) {
continue;
}
if (empty($integration['is_active']) || empty($integration['has_api_key'])) {
continue;
}
if (!$ignoreOrdersFetchEnabled && empty($integration['orders_fetch_enabled'])) {
continue;
}
$result['checked_integrations'] = (int) $result['checked_integrations'] + 1;
$this->syncOneIntegration($integration, $maxPages, $pageLimit, $maxOrders, $result);
}
return $result;
}
/**
* @param array<string, mixed> $integration
* @param array<string, mixed> $result
*/
private function syncOneIntegration(array $integration, int $maxPages, int $pageLimit, int $maxOrders, array &$result): void
{
$integrationId = (int) ($integration['id'] ?? 0);
$state = $this->syncState->getState($integrationId);
$this->syncState->markRunStarted($integrationId, new DateTimeImmutable('now'));
try {
$statusMap = $this->buildStatusMap($integrationId);
$cursorUpdatedAt = StringHelper::nullableString((string) ($state['last_synced_updated_at'] ?? ''));
$cursorOrderId = StringHelper::nullableString((string) ($state['last_synced_source_order_id'] ?? ''));
$startDate = $this->resolveStartDate(
(string) ($integration['orders_fetch_start_date'] ?? ''),
$cursorUpdatedAt
);
$baseUrl = trim((string) ($integration['base_url'] ?? ''));
$apiKey = $this->integrations->getApiKeyDecrypted($integrationId);
$timeout = max(1, min(120, (int) ($integration['timeout_seconds'] ?? 10)));
$productImageCache = [];
if ($baseUrl === '' || $apiKey === null || trim($apiKey) === '') {
throw new \RuntimeException('Brak poprawnych danych API dla integracji.');
}
$latestUpdatedAt = $cursorUpdatedAt;
$latestOrderId = $cursorOrderId;
$shouldStop = false;
for ($page = 1; $page <= $maxPages; $page++) {
$items = $this->fetchOrdersPage($baseUrl, (string) $apiKey, $timeout, $page, $pageLimit, $startDate);
if ($items === []) {
break;
}
$candidates = $this->mapper->buildCandidates($items, $cursorUpdatedAt, $cursorOrderId);
$this->processPageCandidates(
$candidates, $integrationId, $baseUrl, (string) $apiKey, $timeout,
$statusMap, $maxOrders, $result, $productImageCache, $shouldStop,
$latestUpdatedAt, $latestOrderId
);
if ($shouldStop || count($items) < $pageLimit) {
break;
}
}
$this->syncState->markRunSuccess($integrationId, new DateTimeImmutable('now'), $latestUpdatedAt, $latestOrderId);
} catch (Throwable $exception) {
$this->syncState->markRunFailed($integrationId, new DateTimeImmutable('now'), $exception->getMessage());
$result['failed'] = (int) $result['failed'] + 1;
$errors = is_array($result['errors']) ? $result['errors'] : [];
if (count($errors) < 20) {
$errors[] = ['integration_id' => $integrationId, 'error' => $exception->getMessage()];
}
$result['errors'] = $errors;
}
}
/**
* @return array<int, array<string, mixed>>
*/
private function fetchOrdersPage(string $baseUrl, string $apiKey, int $timeout, int $page, int $pageLimit, ?string $startDate): array
{
$orders = $this->apiClient->fetchOrders($baseUrl, $apiKey, $timeout, $page, $pageLimit, $startDate);
if (($orders['ok'] ?? false) !== true) {
throw new \RuntimeException((string) ($orders['message'] ?? 'Blad pobierania listy zamowien.'));
}
return is_array($orders['items'] ?? null) ? $orders['items'] : [];
}
/**
* @param array<int, array<string, mixed>> $candidates
* @param array<string, string> $statusMap
* @param array<string, mixed> $result
* @param array<int, string> $productImageCache
*/
private function processPageCandidates(
array $candidates,
int $integrationId,
string $baseUrl,
string $apiKey,
int $timeout,
array $statusMap,
int $maxOrders,
array &$result,
array &$productImageCache,
bool &$shouldStop,
?string &$latestUpdatedAt,
?string &$latestOrderId
): void {
foreach ($candidates as $candidate) {
if ((int) $result['processed'] >= $maxOrders) {
$shouldStop = true;
break;
}
$sourceOrderId = (string) ($candidate['source_order_id'] ?? '');
$sourceUpdatedAt = (string) ($candidate['source_updated_at'] ?? '');
$rawOrder = is_array($candidate['payload'] ?? null) ? $candidate['payload'] : [];
$details = $this->apiClient->fetchOrderById($baseUrl, $apiKey, $timeout, $sourceOrderId);
if (($details['ok'] ?? false) === true && is_array($details['order'] ?? null)) {
$detailsOrder = (array) $details['order'];
foreach (['products', 'summary', 'paid', 'transport_cost', 'transport', 'transport_description',
'client_name', 'client_surname', 'client_email', 'client_phone', 'client_city',
'client_street', 'client_postal_code'] as $protectedKey) {
if (array_key_exists($protectedKey, $rawOrder)) {
unset($detailsOrder[$protectedKey]);
}
}
$rawOrder = array_replace($rawOrder, $detailsOrder);
}
$this->importOneOrder(
$integrationId, $sourceOrderId, $sourceUpdatedAt, $rawOrder,
$baseUrl, $apiKey, $timeout, $statusMap, $result, $productImageCache
);
if ($latestUpdatedAt === null || $sourceUpdatedAt > $latestUpdatedAt) {
$latestUpdatedAt = $sourceUpdatedAt;
$latestOrderId = $sourceOrderId;
} elseif ($latestUpdatedAt === $sourceUpdatedAt && ($latestOrderId === null || strcmp($sourceOrderId, $latestOrderId) > 0)) {
$latestOrderId = $sourceOrderId;
}
}
}
/**
* @param array<string, mixed> $rawOrder
* @param array<string, string> $statusMap
* @param array<string, mixed> $result
* @param array<int, string> $productImageCache
*/
private function importOneOrder(
int $integrationId,
string $sourceOrderId,
string $sourceUpdatedAt,
array $rawOrder,
string $baseUrl,
string $apiKey,
int $timeout,
array $statusMap,
array &$result,
array &$productImageCache
): void {
try {
$productImages = $this->imageResolver->resolveProductImagesForOrder(
$baseUrl, $apiKey, $timeout, $rawOrder, $productImageCache
);
$aggregate = $this->mapper->mapOrderAggregate(
$integrationId, $rawOrder, $statusMap, $sourceOrderId, $sourceUpdatedAt, $productImages
);
$save = $this->orderImportRepository->upsertOrderAggregate(
$aggregate['order'],
$aggregate['addresses'],
$aggregate['items'],
$aggregate['payments'],
$aggregate['shipments'],
$aggregate['notes'],
$aggregate['status_history']
);
$result['processed'] = (int) $result['processed'] + 1;
if (!empty($save['created'])) {
$result['imported_created'] = (int) $result['imported_created'] + 1;
} else {
$result['imported_updated'] = (int) $result['imported_updated'] + 1;
}
$wasCreated = !empty($save['created']);
$wasPaymentTransition = !empty($save['payment_transition']);
$savedOrderId = (int) ($save['order_id'] ?? 0);
if ($wasPaymentTransition) {
$summary = 'Platnosc potwierdzona z shopPRO — zmiana statusu na w realizacji';
} elseif ($wasCreated) {
$summary = 'Import zamowienia z shopPRO';
} else {
$summary = 'Zaktualizowano zamowienie z shopPRO (re-import)';
}
$details = [
'integration_id' => $integrationId,
'source_order_id' => $sourceOrderId,
'source_updated_at' => $sourceUpdatedAt,
'created' => $wasCreated,
'payment_transition' => $wasPaymentTransition,
'trigger' => 'orders_sync',
'trigger_label' => 'Synchronizacja zamowien',
];
if (!$this->orders->shouldSkipDuplicateImportActivity($savedOrderId, $details)) {
$this->orders->recordActivity(
$savedOrderId,
'import',
$summary,
$details,
'import',
'shopPRO'
);
}
if ($savedOrderId > 0 && !$wasPaymentTransition && $this->automationService !== null) {
$this->automationService->trigger('order.imported', $savedOrderId, [
'source' => 'shoppro',
'created' => $wasCreated,
'integration_id' => $integrationId,
]);
}
} catch (Throwable $exception) {
$result['failed'] = (int) $result['failed'] + 1;
$errors = is_array($result['errors']) ? $result['errors'] : [];
if (count($errors) < 20) {
$errors[] = [
'integration_id' => $integrationId,
'source_order_id' => $sourceOrderId,
'error' => $exception->getMessage(),
];
}
$result['errors'] = $errors;
}
}
/**
* @param mixed $rawIds
* @return array<int, true>
*/
private function normalizeIntegrationIds(mixed $rawIds): array
{
if (!is_array($rawIds)) {
return [];
}
$result = [];
foreach ($rawIds as $rawId) {
$id = (int) $rawId;
if ($id <= 0) {
continue;
}
$result[$id] = true;
}
return $result;
}
/**
* @return array<string, string> shoppro_status_code => orderpro_status_code
*/
private function buildStatusMap(int $integrationId): array
{
if ($this->pullStatusMappings !== null) {
return $this->buildStatusMapFromPullTable($integrationId);
}
return $this->buildStatusMapFromPushTable($integrationId);
}
/**
* @return array<string, string>
*/
private function buildStatusMapFromPullTable(int $integrationId): array
{
$rows = $this->pullStatusMappings->listByIntegration($integrationId);
$map = [];
foreach ($rows as $row) {
$shopCode = strtolower(trim((string) ($row['shoppro_status_code'] ?? '')));
$orderCode = strtolower(trim((string) ($row['orderpro_status_code'] ?? '')));
if ($shopCode === '' || $orderCode === '') {
continue;
}
$map[$shopCode] = $orderCode;
}
return $map;
}
/**
* @return array<string, string>
*/
private function buildStatusMapFromPushTable(int $integrationId): array
{
$rows = $this->statusMappings->listByIntegration($integrationId);
$map = [];
foreach ($rows as $row) {
$shopCode = strtolower(trim((string) ($row['shoppro_status_code'] ?? '')));
$orderCode = strtolower(trim((string) ($row['orderpro_status_code'] ?? '')));
if ($shopCode === '' || $orderCode === '') {
continue;
}
if (!isset($map[$shopCode])) {
$map[$shopCode] = $orderCode;
}
}
return $map;
}
private function resolveStartDate(string $settingsDate, ?string $cursorUpdatedAt): ?string
{
$settings = trim($settingsDate);
$cursor = StringHelper::nullableString((string) $cursorUpdatedAt);
if ($settings === '' && $cursor === null) {
return null;
}
if ($settings === '') {
return $cursor;
}
if ($cursor === null) {
return $settings;
}
return $cursor > $settings ? $cursor : $settings;
}
}