feat(v1.7): orderPRO -> shopPRO status push sync

Implement bidirectional status sync for shopPRO integrations.
When direction is set to orderpro_to_shoppro, cron pushes manual
status changes to shopPRO via PUT API with reverse status mapping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 12:54:57 +01:00
parent 054816b0ba
commit 957fddaf84
13 changed files with 867 additions and 41 deletions

View File

@@ -84,18 +84,26 @@ final class CronHandlerFactory
$shopproIntegrationsRepo = new ShopproIntegrationsRepository($this->db, $this->integrationSecret);
$shopproApiClient = new ShopproApiClient();
$shopproSyncStateRepo = new ShopproOrderSyncStateRepository($this->db);
$shopproStatusMappingRepo = new ShopproStatusMappingRepository($this->db);
$shopproSyncService = new ShopproOrdersSyncService(
$shopproIntegrationsRepo,
new ShopproOrderSyncStateRepository($this->db),
$shopproSyncStateRepo,
$shopproApiClient,
new OrderImportRepository($this->db),
new ShopproStatusMappingRepository($this->db),
$shopproStatusMappingRepo,
$ordersRepository,
new ShopproOrderMapper(),
new ShopproProductImageResolver($shopproApiClient)
);
$shopproStatusSyncService = new ShopproStatusSyncService($shopproIntegrationsRepo, $shopproSyncService);
$shopproStatusSyncService = new ShopproStatusSyncService(
$shopproIntegrationsRepo,
$shopproSyncService,
$shopproApiClient,
$shopproSyncStateRepo,
$shopproStatusMappingRepo,
$this->db
);
$shopproPaymentSyncService = new ShopproPaymentStatusSyncService(
$shopproIntegrationsRepo,
new ShopproApiClient(),

View File

@@ -201,11 +201,80 @@ final class ShopproApiClient
];
}
/**
* @return array{ok:bool,http_code:int|null,message:string,changed:bool}
*/
public function updateOrderStatus(
string $baseUrl,
string $apiKey,
int $timeoutSeconds,
int $orderId,
int $statusId,
bool $sendEmail = false
): array {
if ($orderId <= 0 || $statusId <= 0) {
return [
'ok' => false,
'http_code' => null,
'message' => 'Niepoprawne ID zamowienia lub statusu.',
'changed' => false,
];
}
$url = rtrim(trim($baseUrl), '/') . '/api.php?' . http_build_query([
'endpoint' => 'orders',
'action' => 'change_status',
'id' => $orderId,
]);
$jsonBody = json_encode(['status_id' => $statusId, 'send_email' => $sendEmail], JSON_THROW_ON_ERROR);
$response = $this->requestJsonPut($url, $apiKey, $timeoutSeconds, $jsonBody);
if (($response['ok'] ?? false) !== true) {
return [
'ok' => false,
'http_code' => $response['http_code'] ?? null,
'message' => (string) ($response['message'] ?? 'Nie mozna zaktualizowac statusu zamowienia w shopPRO.'),
'changed' => false,
];
}
$data = is_array($response['data'] ?? null) ? $response['data'] : [];
return [
'ok' => true,
'http_code' => $response['http_code'] ?? null,
'message' => '',
'changed' => !empty($data['changed']),
];
}
/**
* @return array{ok:bool,http_code:int|null,message:string,data:array<string,mixed>|array<int,mixed>|null}
*/
private function requestJson(string $url, string $apiKey, int $timeoutSeconds): array
{
return $this->executeRequest($url, $apiKey, $timeoutSeconds);
}
/**
* @return array{ok:bool,http_code:int|null,message:string,data:array<string,mixed>|array<int,mixed>|null}
*/
private function requestJsonPut(string $url, string $apiKey, int $timeoutSeconds, string $jsonBody): array
{
return $this->executeRequest($url, $apiKey, $timeoutSeconds, 'PUT', $jsonBody);
}
/**
* @return array{ok:bool,http_code:int|null,message:string,data:array<string,mixed>|array<int,mixed>|null}
*/
private function executeRequest(
string $url,
string $apiKey,
int $timeoutSeconds,
string $method = 'GET',
?string $jsonBody = null
): array {
$curl = curl_init($url);
if ($curl === false) {
return [
@@ -216,26 +285,40 @@ final class ShopproApiClient
];
}
$sslOpts = [
$headers = [
'Accept: application/json',
'X-Api-Key: ' . $apiKey,
];
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => max(1, min(120, $timeoutSeconds)),
CURLOPT_CONNECTTIMEOUT => max(1, min(120, $timeoutSeconds)),
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'X-Api-Key: ' . $apiKey,
],
];
if ($method !== 'GET') {
$opts[CURLOPT_CUSTOMREQUEST] = $method;
}
if ($jsonBody !== null) {
$opts[CURLOPT_POSTFIELDS] = $jsonBody;
$headers[] = 'Content-Type: application/json';
}
$opts[CURLOPT_HTTPHEADER] = $headers;
$caPath = $this->getCaBundlePath();
if ($caPath !== null) {
$sslOpts[CURLOPT_CAINFO] = $caPath;
$opts[CURLOPT_CAINFO] = $caPath;
}
curl_setopt_array($curl, $sslOpts);
curl_setopt_array($curl, $opts);
$body = curl_exec($curl);
$httpCode = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
$curlError = trim(curl_error($curl));
curl_close($curl);
if ($body === false) {
return [

View File

@@ -149,6 +149,10 @@ final class ShopproOrderSyncStateRepository
'last_synced_source_order_id' => $sourceOrderIdColumn,
];
if ($columns['has_last_status_pushed_at'] && array_key_exists('last_status_pushed_at', $changes)) {
$columnMap['last_status_pushed_at'] = 'last_status_pushed_at';
}
foreach ($columnMap as $inputKey => $columnName) {
if (!array_key_exists($inputKey, $changes)) {
continue;
@@ -180,6 +184,44 @@ final class ShopproOrderSyncStateRepository
}
}
public function getLastStatusPushedAt(int $integrationId): ?string
{
if ($integrationId <= 0) {
return null;
}
$columns = $this->resolveColumns();
if (!$columns['has_table'] || !$columns['has_last_status_pushed_at']) {
return null;
}
try {
$statement = $this->pdo->prepare(
'SELECT last_status_pushed_at
FROM integration_order_sync_state
WHERE integration_id = :integration_id
LIMIT 1'
);
$statement->execute(['integration_id' => $integrationId]);
$value = $statement->fetchColumn();
if (!is_string($value) || trim($value) === '') {
return null;
}
return trim($value);
} catch (Throwable) {
return null;
}
}
public function updateLastStatusPushedAt(int $integrationId, string $datetime): void
{
$this->upsertState($integrationId, [
'last_status_pushed_at' => $datetime,
]);
}
/**
* @return array{
* has_table:bool,
@@ -199,6 +241,7 @@ final class ShopproOrderSyncStateRepository
'updated_at_column' => null,
'source_order_id_column' => null,
'has_last_success_at' => false,
'has_last_status_pushed_at' => false,
];
try {
@@ -243,6 +286,7 @@ final class ShopproOrderSyncStateRepository
}
$result['has_last_success_at'] = isset($available['last_success_at']);
$result['has_last_status_pushed_at'] = isset($available['last_status_pushed_at']);
$this->columns = $result;
return $result;

View File

@@ -3,6 +3,9 @@ declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
use Throwable;
final class ShopproStatusSyncService
{
private const DIRECTION_SHOPPRO_TO_ORDERPRO = 'shoppro_to_orderpro';
@@ -10,7 +13,11 @@ final class ShopproStatusSyncService
public function __construct(
private readonly ShopproIntegrationsRepository $integrations,
private readonly ShopproOrdersSyncService $ordersSyncService
private readonly ShopproOrdersSyncService $ordersSyncService,
private readonly ShopproApiClient $apiClient,
private readonly ShopproOrderSyncStateRepository $syncState,
private readonly ShopproStatusMappingRepository $statusMappings,
private readonly PDO $pdo
) {
}
@@ -19,8 +26,8 @@ final class ShopproStatusSyncService
*/
public function sync(): array
{
$supportedIntegrationIds = [];
$unsupportedCount = 0;
$pullIntegrationIds = [];
$pushResults = [];
foreach ($this->integrations->listIntegrations() as $integration) {
$integrationId = (int) ($integration['id'] ?? 0);
@@ -29,21 +36,33 @@ final class ShopproStatusSyncService
}
$direction = trim((string) ($integration['order_status_sync_direction'] ?? self::DIRECTION_SHOPPRO_TO_ORDERPRO));
if ($direction === self::DIRECTION_ORDERPRO_TO_SHOPPRO) {
$unsupportedCount++;
$pushResults[] = $this->syncPushDirection($integrationId);
continue;
}
$supportedIntegrationIds[] = $integrationId;
$pullIntegrationIds[] = $integrationId;
}
if ($supportedIntegrationIds === []) {
$result = $this->buildPullResult($pullIntegrationIds);
$result['push_results'] = $pushResults;
return $result;
}
/**
* @param array<int, int> $pullIntegrationIds
* @return array<string, mixed>
*/
private function buildPullResult(array $pullIntegrationIds): array
{
if ($pullIntegrationIds === []) {
return [
'ok' => true,
'processed' => 0,
'checked_integrations' => 0,
'unsupported_integrations' => $unsupportedCount,
'message' => 'Brak aktywnych integracji shopPRO z kierunkiem shopPRO -> orderPRO.',
'direction' => self::DIRECTION_SHOPPRO_TO_ORDERPRO,
];
}
@@ -52,12 +71,209 @@ final class ShopproStatusSyncService
'page_limit' => 50,
'max_orders' => 200,
'ignore_orders_fetch_enabled' => true,
'allowed_integration_ids' => $supportedIntegrationIds,
'allowed_integration_ids' => $pullIntegrationIds,
]);
$result['ok'] = true;
$result['direction'] = self::DIRECTION_SHOPPRO_TO_ORDERPRO;
$result['unsupported_integrations'] = $unsupportedCount;
return $result;
}
/**
* @return array<string, mixed>
*/
private function syncPushDirection(int $integrationId): array
{
$pushed = 0;
$skipped = 0;
$failed = 0;
try {
$baseUrl = $this->resolveBaseUrl($integrationId);
$apiKey = $this->integrations->getApiKeyDecrypted($integrationId);
$timeout = $this->resolveTimeout($integrationId);
if ($baseUrl === '' || $apiKey === null || trim($apiKey) === '') {
return [
'ok' => false,
'integration_id' => $integrationId,
'direction' => self::DIRECTION_ORDERPRO_TO_SHOPPRO,
'message' => 'Brak poprawnych danych API dla integracji.',
'pushed' => 0,
'skipped' => 0,
'failed' => 0,
];
}
$reverseMap = $this->buildReverseStatusMap($integrationId);
if ($reverseMap === []) {
return [
'ok' => true,
'integration_id' => $integrationId,
'direction' => self::DIRECTION_ORDERPRO_TO_SHOPPRO,
'message' => 'Brak mapowania statusow dla integracji.',
'pushed' => 0,
'skipped' => 0,
'failed' => 0,
];
}
$lastPushedAt = $this->syncState->getLastStatusPushedAt($integrationId);
$orders = $this->findOrdersWithManualStatusChanges($integrationId, $lastPushedAt);
if ($orders === []) {
return [
'ok' => true,
'integration_id' => $integrationId,
'direction' => self::DIRECTION_ORDERPRO_TO_SHOPPRO,
'message' => 'Brak zamowien do synchronizacji.',
'pushed' => 0,
'skipped' => 0,
'failed' => 0,
];
}
$latestChangeAt = $lastPushedAt;
foreach ($orders as $order) {
$sourceOrderId = (int) ($order['source_order_id'] ?? 0);
$orderproStatus = strtolower(trim((string) ($order['external_status_id'] ?? '')));
$changeAt = (string) ($order['latest_change'] ?? '');
if ($sourceOrderId <= 0 || $orderproStatus === '') {
$skipped++;
continue;
}
$shopproStatusCode = $reverseMap[$orderproStatus] ?? null;
if ($shopproStatusCode === null) {
$skipped++;
continue;
}
$shopproStatusId = (int) $shopproStatusCode;
if ($shopproStatusId <= 0) {
$skipped++;
continue;
}
$apiResult = $this->apiClient->updateOrderStatus(
$baseUrl,
$apiKey,
$timeout,
$sourceOrderId,
$shopproStatusId,
false
);
if (($apiResult['ok'] ?? false) === true) {
$pushed++;
} else {
$failed++;
}
if ($changeAt !== '' && ($latestChangeAt === null || $changeAt > $latestChangeAt)) {
$latestChangeAt = $changeAt;
}
}
if ($latestChangeAt !== null && $latestChangeAt !== $lastPushedAt) {
$this->syncState->updateLastStatusPushedAt($integrationId, $latestChangeAt);
}
} catch (Throwable $e) {
return [
'ok' => false,
'integration_id' => $integrationId,
'direction' => self::DIRECTION_ORDERPRO_TO_SHOPPRO,
'message' => $e->getMessage(),
'pushed' => $pushed,
'skipped' => $skipped,
'failed' => $failed,
];
}
return [
'ok' => true,
'integration_id' => $integrationId,
'direction' => self::DIRECTION_ORDERPRO_TO_SHOPPRO,
'pushed' => $pushed,
'skipped' => $skipped,
'failed' => $failed,
];
}
/**
* @return array<string, string> orderpro_status_code => shoppro_status_code
*/
private function buildReverseStatusMap(int $integrationId): array
{
$rows = $this->statusMappings->listByIntegration($integrationId);
$map = [];
foreach ($rows as $row) {
$shopCode = trim((string) ($row['shoppro_status_code'] ?? ''));
$orderCode = strtolower(trim((string) ($row['orderpro_status_code'] ?? '')));
if ($shopCode === '' || $orderCode === '') {
continue;
}
if (!isset($map[$orderCode])) {
$map[$orderCode] = $shopCode;
}
}
return $map;
}
/**
* @return array<int, array{order_id:int,source_order_id:string,external_status_id:string,latest_change:string}>
*/
private function findOrdersWithManualStatusChanges(int $integrationId, ?string $lastPushedAt): array
{
$fallbackDate = date('Y-m-d H:i:s', strtotime('-24 hours'));
$sinceDate = ($lastPushedAt !== null && $lastPushedAt !== '') ? $lastPushedAt : $fallbackDate;
try {
$statement = $this->pdo->prepare(
'SELECT o.id AS order_id, o.source_order_id, o.external_status_id,
MAX(h.changed_at) AS latest_change
FROM order_status_history h
JOIN orders o ON o.id = h.order_id
WHERE o.integration_id = :integration_id
AND h.change_source = :change_source
AND h.changed_at > :since_date
GROUP BY o.id, o.source_order_id, o.external_status_id
ORDER BY latest_change ASC
LIMIT 50'
);
$statement->execute([
'integration_id' => $integrationId,
'change_source' => 'manual',
'since_date' => $sinceDate,
]);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
} catch (Throwable) {
return [];
}
}
private function resolveBaseUrl(int $integrationId): string
{
$integration = $this->integrations->findIntegration($integrationId);
if ($integration === null) {
return '';
}
return rtrim(trim((string) ($integration['base_url'] ?? '')), '/');
}
private function resolveTimeout(int $integrationId): int
{
$integration = $this->integrations->findIntegration($integrationId);
if ($integration === null) {
return 10;
}
return max(1, min(120, (int) ($integration['timeout_seconds'] ?? 10)));
}
}