386 lines
15 KiB
PHP
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;
|
|
}
|
|
|
|
}
|