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:
@@ -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(),
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user