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.
This commit is contained in:
@@ -13,7 +13,21 @@ use App\Core\Support\Logger;
|
||||
use App\Core\Support\Session;
|
||||
use App\Core\View\Template;
|
||||
use App\Modules\Auth\AuthService;
|
||||
use App\Modules\Cron\AllegroOrdersImportHandler;
|
||||
use App\Modules\Cron\AllegroStatusSyncHandler;
|
||||
use App\Modules\Cron\AllegroTokenRefreshHandler;
|
||||
use App\Modules\Cron\CronRepository;
|
||||
use App\Modules\Cron\CronRunner;
|
||||
use App\Modules\Orders\OrderImportRepository;
|
||||
use App\Modules\Orders\OrdersRepository;
|
||||
use App\Modules\Settings\AllegroApiClient;
|
||||
use App\Modules\Settings\AllegroIntegrationRepository;
|
||||
use App\Modules\Settings\AllegroOrderImportService;
|
||||
use App\Modules\Settings\AllegroOrdersSyncService;
|
||||
use App\Modules\Settings\AllegroOrderSyncStateRepository;
|
||||
use App\Modules\Settings\AllegroOAuthClient;
|
||||
use App\Modules\Settings\AllegroStatusSyncService;
|
||||
use App\Modules\Settings\AllegroStatusMappingRepository;
|
||||
use App\Modules\Settings\OrderStatusRepository;
|
||||
use App\Modules\Users\UserRepository;
|
||||
use Throwable;
|
||||
@@ -70,6 +84,7 @@ final class Application
|
||||
public function run(): void
|
||||
{
|
||||
$request = Request::capture();
|
||||
$this->maybeRunCronOnWeb($request);
|
||||
$response = $this->router->dispatch($request);
|
||||
$response->send();
|
||||
}
|
||||
@@ -215,7 +230,87 @@ final class Application
|
||||
|
||||
private function maybeRunCronOnWeb(Request $request): void
|
||||
{
|
||||
return;
|
||||
$path = $request->path();
|
||||
if ($path === '/health' || str_starts_with($path, '/assets/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$repository = new CronRepository($this->db);
|
||||
$runOnWeb = $repository->getBoolSetting(
|
||||
'cron_run_on_web',
|
||||
(bool) $this->config('app.cron.run_on_web_default', false)
|
||||
);
|
||||
if (!$runOnWeb) {
|
||||
return;
|
||||
}
|
||||
|
||||
$webLimit = $repository->getIntSetting(
|
||||
'cron_web_limit',
|
||||
(int) $this->config('app.cron.web_limit_default', 5),
|
||||
1,
|
||||
100
|
||||
);
|
||||
|
||||
if ($this->isWebCronThrottled(10)) {
|
||||
return;
|
||||
}
|
||||
if (!$this->acquireWebCronLock()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$integrationRepository = new AllegroIntegrationRepository(
|
||||
$this->db,
|
||||
(string) $this->config('app.integrations.secret', '')
|
||||
);
|
||||
$oauthClient = new AllegroOAuthClient();
|
||||
$apiClient = new AllegroApiClient();
|
||||
$statusMappingRepository = new AllegroStatusMappingRepository($this->db);
|
||||
$orderImportService = new AllegroOrderImportService(
|
||||
$integrationRepository,
|
||||
$oauthClient,
|
||||
$apiClient,
|
||||
new OrderImportRepository($this->db),
|
||||
$statusMappingRepository
|
||||
);
|
||||
$ordersSyncService = new AllegroOrdersSyncService(
|
||||
$integrationRepository,
|
||||
new AllegroOrderSyncStateRepository($this->db),
|
||||
$oauthClient,
|
||||
$apiClient,
|
||||
$orderImportService
|
||||
);
|
||||
|
||||
$runner = new CronRunner(
|
||||
$repository,
|
||||
$this->logger,
|
||||
[
|
||||
'allegro_token_refresh' => new AllegroTokenRefreshHandler(
|
||||
$integrationRepository,
|
||||
$oauthClient
|
||||
),
|
||||
'allegro_orders_import' => new AllegroOrdersImportHandler(
|
||||
$ordersSyncService
|
||||
),
|
||||
'allegro_status_sync' => new AllegroStatusSyncHandler(
|
||||
new AllegroStatusSyncService(
|
||||
$repository,
|
||||
$ordersSyncService
|
||||
)
|
||||
),
|
||||
]
|
||||
);
|
||||
$runner->run($webLimit);
|
||||
} finally {
|
||||
$this->releaseWebCronLock();
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$this->logger->error('Web cron run failed', [
|
||||
'message' => $exception->getMessage(),
|
||||
'path' => $path,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function isWebCronThrottled(int $minIntervalSeconds): bool
|
||||
|
||||
26
src/Modules/Cron/AllegroOrdersImportHandler.php
Normal file
26
src/Modules/Cron/AllegroOrdersImportHandler.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Cron;
|
||||
|
||||
use App\Modules\Settings\AllegroOrdersSyncService;
|
||||
|
||||
final class AllegroOrdersImportHandler
|
||||
{
|
||||
public function __construct(private readonly AllegroOrdersSyncService $syncService)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function handle(array $payload): array
|
||||
{
|
||||
return $this->syncService->sync([
|
||||
'max_pages' => (int) ($payload['max_pages'] ?? 5),
|
||||
'page_limit' => (int) ($payload['page_limit'] ?? 50),
|
||||
'max_orders' => (int) ($payload['max_orders'] ?? 200),
|
||||
]);
|
||||
}
|
||||
}
|
||||
22
src/Modules/Cron/AllegroStatusSyncHandler.php
Normal file
22
src/Modules/Cron/AllegroStatusSyncHandler.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Cron;
|
||||
|
||||
use App\Modules\Settings\AllegroStatusSyncService;
|
||||
|
||||
final class AllegroStatusSyncHandler
|
||||
{
|
||||
public function __construct(private readonly AllegroStatusSyncService $syncService)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function handle(array $payload): array
|
||||
{
|
||||
return $this->syncService->sync();
|
||||
}
|
||||
}
|
||||
64
src/Modules/Cron/AllegroTokenRefreshHandler.php
Normal file
64
src/Modules/Cron/AllegroTokenRefreshHandler.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Cron;
|
||||
|
||||
use App\Modules\Settings\AllegroIntegrationRepository;
|
||||
use App\Modules\Settings\AllegroOAuthClient;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use RuntimeException;
|
||||
|
||||
final class AllegroTokenRefreshHandler
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AllegroIntegrationRepository $repository,
|
||||
private readonly AllegroOAuthClient $oauthClient
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function handle(array $payload): array
|
||||
{
|
||||
$credentials = $this->repository->getRefreshTokenCredentials();
|
||||
if ($credentials === null) {
|
||||
throw new RuntimeException('Brak kompletnych danych Allegro OAuth do odswiezenia tokenu.');
|
||||
}
|
||||
|
||||
$token = $this->oauthClient->refreshAccessToken(
|
||||
(string) ($credentials['environment'] ?? 'sandbox'),
|
||||
(string) ($credentials['client_id'] ?? ''),
|
||||
(string) ($credentials['client_secret'] ?? ''),
|
||||
(string) ($credentials['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) ($credentials['refresh_token'] ?? '');
|
||||
}
|
||||
|
||||
$this->repository->saveTokens(
|
||||
(string) ($token['access_token'] ?? ''),
|
||||
$refreshToken,
|
||||
(string) ($token['token_type'] ?? ''),
|
||||
(string) ($token['scope'] ?? ''),
|
||||
$expiresAt
|
||||
);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'expires_at' => $expiresAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
448
src/Modules/Cron/CronRepository.php
Normal file
448
src/Modules/Cron/CronRepository.php
Normal file
@@ -0,0 +1,448 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Cron;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
final class CronRepository
|
||||
{
|
||||
public function __construct(private readonly PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
public function getBoolSetting(string $key, bool $default): bool
|
||||
{
|
||||
$value = $this->getSettingValue($key);
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
public function getIntSetting(string $key, int $default, int $min, int $max): int
|
||||
{
|
||||
$value = $this->getSettingValue($key);
|
||||
if ($value === null || !is_numeric($value)) {
|
||||
return max($min, min($max, $default));
|
||||
}
|
||||
|
||||
return max($min, min($max, (int) $value));
|
||||
}
|
||||
|
||||
public function getStringSetting(string $key, string $default): string
|
||||
{
|
||||
$value = $this->getSettingValue($key);
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function upsertSetting(string $key, string $value): void
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO app_settings (setting_key, setting_value, created_at, updated_at)
|
||||
VALUES (:setting_key, :setting_value, NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
setting_value = VALUES(setting_value),
|
||||
updated_at = VALUES(updated_at)'
|
||||
);
|
||||
$statement->execute([
|
||||
'setting_key' => $key,
|
||||
'setting_value' => $value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function listSchedules(): array
|
||||
{
|
||||
$statement = $this->pdo->query(
|
||||
'SELECT id, job_type, interval_seconds, priority, max_attempts, payload, enabled, last_run_at, next_run_at
|
||||
FROM cron_schedules
|
||||
ORDER BY priority ASC, job_type ASC'
|
||||
);
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(fn (array $row): array => $this->normalizeScheduleRow($row), $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function listFutureJobs(int $limit = 50): array
|
||||
{
|
||||
$safeLimit = max(1, min(200, $limit));
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, job_type, status, priority, attempts, max_attempts, scheduled_at, last_error, created_at
|
||||
FROM cron_jobs
|
||||
WHERE status IN ("pending", "processing")
|
||||
ORDER BY scheduled_at ASC, priority ASC, id ASC
|
||||
LIMIT :limit'
|
||||
);
|
||||
$statement->bindValue(':limit', $safeLimit, PDO::PARAM_INT);
|
||||
$statement->execute();
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(fn (array $row): array => $this->normalizeJobRow($row), $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function listPastJobs(int $limit = 50): array
|
||||
{
|
||||
$safeLimit = max(1, min(200, $limit));
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, job_type, status, priority, attempts, max_attempts, scheduled_at, started_at, completed_at, last_error, created_at
|
||||
FROM cron_jobs
|
||||
WHERE status IN ("completed", "failed", "cancelled")
|
||||
ORDER BY completed_at DESC, id DESC
|
||||
LIMIT :limit'
|
||||
);
|
||||
$statement->bindValue(':limit', $safeLimit, PDO::PARAM_INT);
|
||||
$statement->execute();
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(fn (array $row): array => $this->normalizeJobRow($row), $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function findDueSchedules(DateTimeImmutable $now): array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, job_type, interval_seconds, priority, max_attempts, payload, enabled, last_run_at, next_run_at
|
||||
FROM cron_schedules
|
||||
WHERE enabled = 1
|
||||
AND (next_run_at IS NULL OR next_run_at <= :now)
|
||||
ORDER BY priority ASC, next_run_at ASC, id ASC'
|
||||
);
|
||||
$statement->execute([
|
||||
'now' => $now->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(fn (array $row): array => $this->normalizeScheduleRow($row), $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $schedule
|
||||
*/
|
||||
public function enqueueJobFromSchedule(array $schedule, DateTimeImmutable $now): void
|
||||
{
|
||||
$payloadJson = $this->encodeJson($schedule['payload'] ?? null);
|
||||
|
||||
$jobStatement = $this->pdo->prepare(
|
||||
'INSERT INTO cron_jobs (
|
||||
job_type, status, priority, payload, attempts, max_attempts,
|
||||
scheduled_at, created_at, updated_at
|
||||
) VALUES (
|
||||
:job_type, "pending", :priority, :payload, 0, :max_attempts,
|
||||
:scheduled_at, NOW(), NOW()
|
||||
)'
|
||||
);
|
||||
$jobStatement->execute([
|
||||
'job_type' => (string) ($schedule['job_type'] ?? ''),
|
||||
'priority' => (int) ($schedule['priority'] ?? 100),
|
||||
'payload' => $payloadJson,
|
||||
'max_attempts' => max(1, (int) ($schedule['max_attempts'] ?? 3)),
|
||||
'scheduled_at' => $now->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$intervalSeconds = max(1, (int) ($schedule['interval_seconds'] ?? 60));
|
||||
$nextRunAt = $now->modify('+' . $intervalSeconds . ' seconds');
|
||||
|
||||
$scheduleStatement = $this->pdo->prepare(
|
||||
'UPDATE cron_schedules
|
||||
SET last_run_at = :last_run_at,
|
||||
next_run_at = :next_run_at,
|
||||
updated_at = NOW()
|
||||
WHERE id = :id'
|
||||
);
|
||||
$scheduleStatement->execute([
|
||||
'last_run_at' => $now->format('Y-m-d H:i:s'),
|
||||
'next_run_at' => $nextRunAt->format('Y-m-d H:i:s'),
|
||||
'id' => (int) ($schedule['id'] ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function claimNextPendingJob(DateTimeImmutable $now): ?array
|
||||
{
|
||||
$selectStatement = $this->pdo->prepare(
|
||||
'SELECT id, job_type, status, priority, payload, attempts, max_attempts, scheduled_at
|
||||
FROM cron_jobs
|
||||
WHERE status = "pending"
|
||||
AND scheduled_at <= :now
|
||||
ORDER BY priority ASC, scheduled_at ASC, id ASC
|
||||
LIMIT 1'
|
||||
);
|
||||
$selectStatement->execute([
|
||||
'now' => $now->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
$row = $selectStatement->fetch(PDO::FETCH_ASSOC);
|
||||
if (!is_array($row)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$jobId = (int) ($row['id'] ?? 0);
|
||||
if ($jobId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$updateStatement = $this->pdo->prepare(
|
||||
'UPDATE cron_jobs
|
||||
SET status = "processing",
|
||||
started_at = :started_at,
|
||||
attempts = attempts + 1,
|
||||
updated_at = NOW()
|
||||
WHERE id = :id
|
||||
AND status = "pending"'
|
||||
);
|
||||
$updateStatement->execute([
|
||||
'started_at' => $now->format('Y-m-d H:i:s'),
|
||||
'id' => $jobId,
|
||||
]);
|
||||
|
||||
if ($updateStatement->rowCount() !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$refreshStatement = $this->pdo->prepare(
|
||||
'SELECT id, job_type, status, priority, payload, attempts, max_attempts, scheduled_at, started_at
|
||||
FROM cron_jobs
|
||||
WHERE id = :id
|
||||
LIMIT 1'
|
||||
);
|
||||
$refreshStatement->execute(['id' => $jobId]);
|
||||
$claimed = $refreshStatement->fetch(PDO::FETCH_ASSOC);
|
||||
if (!is_array($claimed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->normalizeJobRow($claimed);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $result
|
||||
*/
|
||||
public function markJobCompleted(int $jobId, ?array $result = null): void
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'UPDATE cron_jobs
|
||||
SET status = "completed",
|
||||
result = :result,
|
||||
completed_at = NOW(),
|
||||
last_error = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = :id'
|
||||
);
|
||||
$statement->execute([
|
||||
'result' => $this->encodeJson($result),
|
||||
'id' => $jobId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $result
|
||||
*/
|
||||
public function markJobFailed(int $jobId, string $error, DateTimeImmutable $now, int $retryDelaySeconds = 60, ?array $result = null): void
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT attempts, max_attempts
|
||||
FROM cron_jobs
|
||||
WHERE id = :id
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute(['id' => $jobId]);
|
||||
$row = $statement->fetch(PDO::FETCH_ASSOC);
|
||||
if (!is_array($row)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$attempts = (int) ($row['attempts'] ?? 0);
|
||||
$maxAttempts = max(1, (int) ($row['max_attempts'] ?? 1));
|
||||
$errorMessage = mb_substr(trim($error), 0, 500);
|
||||
|
||||
if ($attempts < $maxAttempts) {
|
||||
$scheduledAt = $now->modify('+' . max(10, $retryDelaySeconds) . ' seconds');
|
||||
$retryStatement = $this->pdo->prepare(
|
||||
'UPDATE cron_jobs
|
||||
SET status = "pending",
|
||||
result = :result,
|
||||
scheduled_at = :scheduled_at,
|
||||
started_at = NULL,
|
||||
completed_at = NULL,
|
||||
last_error = :last_error,
|
||||
updated_at = NOW()
|
||||
WHERE id = :id'
|
||||
);
|
||||
$retryStatement->execute([
|
||||
'result' => $this->encodeJson($result),
|
||||
'scheduled_at' => $scheduledAt->format('Y-m-d H:i:s'),
|
||||
'last_error' => $errorMessage,
|
||||
'id' => $jobId,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$failStatement = $this->pdo->prepare(
|
||||
'UPDATE cron_jobs
|
||||
SET status = "failed",
|
||||
result = :result,
|
||||
completed_at = NOW(),
|
||||
last_error = :last_error,
|
||||
updated_at = NOW()
|
||||
WHERE id = :id'
|
||||
);
|
||||
$failStatement->execute([
|
||||
'result' => $this->encodeJson($result),
|
||||
'last_error' => $errorMessage,
|
||||
'id' => $jobId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function upsertSchedule(
|
||||
string $jobType,
|
||||
int $intervalSeconds,
|
||||
int $priority,
|
||||
int $maxAttempts,
|
||||
?array $payload,
|
||||
bool $enabled
|
||||
): void {
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO cron_schedules (
|
||||
job_type, interval_seconds, priority, max_attempts, payload, enabled, last_run_at, next_run_at, created_at, updated_at
|
||||
) VALUES (
|
||||
:job_type, :interval_seconds, :priority, :max_attempts, :payload, :enabled, NULL, NOW(), NOW(), NOW()
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
interval_seconds = VALUES(interval_seconds),
|
||||
priority = VALUES(priority),
|
||||
max_attempts = VALUES(max_attempts),
|
||||
payload = VALUES(payload),
|
||||
enabled = VALUES(enabled),
|
||||
updated_at = VALUES(updated_at)'
|
||||
);
|
||||
$statement->execute([
|
||||
'job_type' => trim($jobType),
|
||||
'interval_seconds' => max(1, $intervalSeconds),
|
||||
'priority' => max(1, min(255, $priority)),
|
||||
'max_attempts' => max(1, min(20, $maxAttempts)),
|
||||
'payload' => $this->encodeJson($payload),
|
||||
'enabled' => $enabled ? 1 : 0,
|
||||
]);
|
||||
}
|
||||
|
||||
private function getSettingValue(string $key): ?string
|
||||
{
|
||||
try {
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT setting_value
|
||||
FROM app_settings
|
||||
WHERE setting_key = :setting_key
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute(['setting_key' => $key]);
|
||||
$value = $statement->fetchColumn();
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeScheduleRow(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'job_type' => (string) ($row['job_type'] ?? ''),
|
||||
'interval_seconds' => (int) ($row['interval_seconds'] ?? 0),
|
||||
'priority' => (int) ($row['priority'] ?? 100),
|
||||
'max_attempts' => (int) ($row['max_attempts'] ?? 3),
|
||||
'payload' => $this->decodeJson((string) ($row['payload'] ?? '')),
|
||||
'enabled' => (int) ($row['enabled'] ?? 0) === 1,
|
||||
'last_run_at' => (string) ($row['last_run_at'] ?? ''),
|
||||
'next_run_at' => (string) ($row['next_run_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeJobRow(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'job_type' => (string) ($row['job_type'] ?? ''),
|
||||
'status' => (string) ($row['status'] ?? ''),
|
||||
'priority' => (int) ($row['priority'] ?? 100),
|
||||
'payload' => $this->decodeJson((string) ($row['payload'] ?? '')),
|
||||
'result' => $this->decodeJson((string) ($row['result'] ?? '')),
|
||||
'attempts' => (int) ($row['attempts'] ?? 0),
|
||||
'max_attempts' => (int) ($row['max_attempts'] ?? 3),
|
||||
'scheduled_at' => (string) ($row['scheduled_at'] ?? ''),
|
||||
'started_at' => (string) ($row['started_at'] ?? ''),
|
||||
'completed_at' => (string) ($row['completed_at'] ?? ''),
|
||||
'last_error' => (string) ($row['last_error'] ?? ''),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function encodeJson(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
if (!is_array($value)) {
|
||||
return null;
|
||||
}
|
||||
if ($value === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: null;
|
||||
}
|
||||
|
||||
private function decodeJson(string $value): ?array
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($trimmed, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
}
|
||||
99
src/Modules/Cron/CronRunner.php
Normal file
99
src/Modules/Cron/CronRunner.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Cron;
|
||||
|
||||
use App\Core\Support\Logger;
|
||||
use DateTimeImmutable;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class CronRunner
|
||||
{
|
||||
/**
|
||||
* @param array<string, object> $handlers
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly CronRepository $repository,
|
||||
private readonly Logger $logger,
|
||||
private readonly array $handlers
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function run(int $limit): array
|
||||
{
|
||||
$safeLimit = max(1, min(100, $limit));
|
||||
$now = new DateTimeImmutable('now');
|
||||
|
||||
$dispatched = $this->dispatchDueSchedules($now);
|
||||
$processed = 0;
|
||||
$completed = 0;
|
||||
$failed = 0;
|
||||
|
||||
while ($processed < $safeLimit) {
|
||||
$job = $this->repository->claimNextPendingJob(new DateTimeImmutable('now'));
|
||||
if ($job === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
$processed++;
|
||||
$jobId = (int) ($job['id'] ?? 0);
|
||||
$jobType = (string) ($job['job_type'] ?? '');
|
||||
|
||||
try {
|
||||
$result = $this->handleJob($jobType, is_array($job['payload'] ?? null) ? $job['payload'] : []);
|
||||
$this->repository->markJobCompleted($jobId, $result);
|
||||
$completed++;
|
||||
} catch (Throwable $exception) {
|
||||
$this->repository->markJobFailed($jobId, $exception->getMessage(), new DateTimeImmutable('now'), 60);
|
||||
$this->logger->error('Cron job failed', [
|
||||
'job_id' => $jobId,
|
||||
'job_type' => $jobType,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'dispatched' => $dispatched,
|
||||
'processed' => $processed,
|
||||
'completed' => $completed,
|
||||
'failed' => $failed,
|
||||
];
|
||||
}
|
||||
|
||||
private function dispatchDueSchedules(DateTimeImmutable $now): int
|
||||
{
|
||||
$schedules = $this->repository->findDueSchedules($now);
|
||||
$count = 0;
|
||||
foreach ($schedules as $schedule) {
|
||||
$this->repository->enqueueJobFromSchedule($schedule, $now);
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function handleJob(string $jobType, array $payload): array
|
||||
{
|
||||
$handler = $this->handlers[$jobType] ?? null;
|
||||
if ($handler === null || !method_exists($handler, 'handle')) {
|
||||
throw new RuntimeException('Brak handlera dla typu joba: ' . $jobType);
|
||||
}
|
||||
|
||||
$result = $handler->handle($payload);
|
||||
if (!is_array($result)) {
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
421
src/Modules/Orders/OrderImportRepository.php
Normal file
421
src/Modules/Orders/OrderImportRepository.php
Normal file
@@ -0,0 +1,421 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Orders;
|
||||
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
final class OrderImportRepository
|
||||
{
|
||||
public function __construct(private readonly PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $orderData
|
||||
* @param array<int, array<string, mixed>> $addresses
|
||||
* @param array<int, array<string, mixed>> $items
|
||||
* @param array<int, array<string, mixed>> $payments
|
||||
* @param array<int, array<string, mixed>> $shipments
|
||||
* @param array<int, array<string, mixed>> $notes
|
||||
* @param array<int, array<string, mixed>> $statusHistory
|
||||
* @return array{order_id:int, created:bool}
|
||||
*/
|
||||
public function upsertOrderAggregate(
|
||||
array $orderData,
|
||||
array $addresses,
|
||||
array $items,
|
||||
array $payments,
|
||||
array $shipments,
|
||||
array $notes,
|
||||
array $statusHistory
|
||||
): array {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$source = trim((string) ($orderData['source'] ?? 'allegro'));
|
||||
$sourceOrderId = trim((string) ($orderData['source_order_id'] ?? ''));
|
||||
$existingOrderId = $this->findOrderIdBySource($source, $sourceOrderId);
|
||||
$created = $existingOrderId === null;
|
||||
$orderId = $created
|
||||
? $this->insertOrder($orderData)
|
||||
: $this->updateOrder($existingOrderId, $orderData);
|
||||
|
||||
$this->replaceAddresses($orderId, $addresses);
|
||||
$this->replaceItems($orderId, $items);
|
||||
$this->replacePayments($orderId, $payments);
|
||||
$this->replaceShipments($orderId, $shipments);
|
||||
$this->replaceNotes($orderId, $notes);
|
||||
$this->replaceStatusHistory($orderId, $statusHistory);
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (Throwable $exception) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return [
|
||||
'order_id' => $orderId,
|
||||
'created' => $created,
|
||||
];
|
||||
}
|
||||
|
||||
private function findOrderIdBySource(string $source, string $sourceOrderId): ?int
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id
|
||||
FROM orders
|
||||
WHERE source = :source AND source_order_id = :source_order_id
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'source' => $source,
|
||||
'source_order_id' => $sourceOrderId,
|
||||
]);
|
||||
$value = $statement->fetchColumn();
|
||||
if ($value === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$id = (int) $value;
|
||||
return $id > 0 ? $id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $orderData
|
||||
*/
|
||||
private function insertOrder(array $orderData): int
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO orders (
|
||||
integration_id, source, source_order_id, external_order_id, external_platform_id, external_platform_account_id,
|
||||
external_status_id, external_payment_type_id, payment_status, external_carrier_id, external_carrier_account_id,
|
||||
customer_login, is_invoice, is_encrypted, is_canceled_by_buyer, currency,
|
||||
total_without_tax, total_with_tax, total_paid, send_date_min, send_date_max, ordered_at,
|
||||
source_created_at, source_updated_at, preferences_json, payload_json, fetched_at
|
||||
) VALUES (
|
||||
:integration_id, :source, :source_order_id, :external_order_id, :external_platform_id, :external_platform_account_id,
|
||||
:external_status_id, :external_payment_type_id, :payment_status, :external_carrier_id, :external_carrier_account_id,
|
||||
:customer_login, :is_invoice, :is_encrypted, :is_canceled_by_buyer, :currency,
|
||||
:total_without_tax, :total_with_tax, :total_paid, :send_date_min, :send_date_max, :ordered_at,
|
||||
:source_created_at, :source_updated_at, :preferences_json, :payload_json, :fetched_at
|
||||
)'
|
||||
);
|
||||
$statement->execute($this->orderParams($orderData));
|
||||
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $orderData
|
||||
*/
|
||||
private function updateOrder(int $orderId, array $orderData): int
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'UPDATE orders
|
||||
SET integration_id = :integration_id,
|
||||
source = :source,
|
||||
source_order_id = :source_order_id,
|
||||
external_order_id = :external_order_id,
|
||||
external_platform_id = :external_platform_id,
|
||||
external_platform_account_id = :external_platform_account_id,
|
||||
external_status_id = :external_status_id,
|
||||
external_payment_type_id = :external_payment_type_id,
|
||||
payment_status = :payment_status,
|
||||
external_carrier_id = :external_carrier_id,
|
||||
external_carrier_account_id = :external_carrier_account_id,
|
||||
customer_login = :customer_login,
|
||||
is_invoice = :is_invoice,
|
||||
is_encrypted = :is_encrypted,
|
||||
is_canceled_by_buyer = :is_canceled_by_buyer,
|
||||
currency = :currency,
|
||||
total_without_tax = :total_without_tax,
|
||||
total_with_tax = :total_with_tax,
|
||||
total_paid = :total_paid,
|
||||
send_date_min = :send_date_min,
|
||||
send_date_max = :send_date_max,
|
||||
ordered_at = :ordered_at,
|
||||
source_created_at = :source_created_at,
|
||||
source_updated_at = :source_updated_at,
|
||||
preferences_json = :preferences_json,
|
||||
payload_json = :payload_json,
|
||||
fetched_at = :fetched_at,
|
||||
updated_at = NOW()
|
||||
WHERE id = :id'
|
||||
);
|
||||
|
||||
$params = $this->orderParams($orderData);
|
||||
$params['id'] = $orderId;
|
||||
$statement->execute($params);
|
||||
|
||||
return $orderId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $orderData
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function orderParams(array $orderData): array
|
||||
{
|
||||
return [
|
||||
'integration_id' => $orderData['integration_id'] ?? null,
|
||||
'source' => (string) ($orderData['source'] ?? 'allegro'),
|
||||
'source_order_id' => (string) ($orderData['source_order_id'] ?? ''),
|
||||
'external_order_id' => $orderData['external_order_id'] ?? null,
|
||||
'external_platform_id' => $orderData['external_platform_id'] ?? null,
|
||||
'external_platform_account_id' => $orderData['external_platform_account_id'] ?? null,
|
||||
'external_status_id' => $orderData['external_status_id'] ?? null,
|
||||
'external_payment_type_id' => $orderData['external_payment_type_id'] ?? null,
|
||||
'payment_status' => $orderData['payment_status'] ?? null,
|
||||
'external_carrier_id' => $orderData['external_carrier_id'] ?? null,
|
||||
'external_carrier_account_id' => $orderData['external_carrier_account_id'] ?? null,
|
||||
'customer_login' => $orderData['customer_login'] ?? null,
|
||||
'is_invoice' => !empty($orderData['is_invoice']) ? 1 : 0,
|
||||
'is_encrypted' => !empty($orderData['is_encrypted']) ? 1 : 0,
|
||||
'is_canceled_by_buyer' => !empty($orderData['is_canceled_by_buyer']) ? 1 : 0,
|
||||
'currency' => (string) ($orderData['currency'] ?? 'PLN'),
|
||||
'total_without_tax' => $orderData['total_without_tax'] ?? null,
|
||||
'total_with_tax' => $orderData['total_with_tax'] ?? null,
|
||||
'total_paid' => $orderData['total_paid'] ?? null,
|
||||
'send_date_min' => $orderData['send_date_min'] ?? null,
|
||||
'send_date_max' => $orderData['send_date_max'] ?? null,
|
||||
'ordered_at' => $orderData['ordered_at'] ?? null,
|
||||
'source_created_at' => $orderData['source_created_at'] ?? null,
|
||||
'source_updated_at' => $orderData['source_updated_at'] ?? null,
|
||||
'preferences_json' => $this->encodeJson($orderData['preferences_json'] ?? null),
|
||||
'payload_json' => $this->encodeJson($orderData['payload_json'] ?? null),
|
||||
'fetched_at' => $orderData['fetched_at'] ?? date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
*/
|
||||
private function replaceAddresses(int $orderId, array $rows): void
|
||||
{
|
||||
$this->pdo->prepare('DELETE FROM order_addresses WHERE order_id = :order_id')->execute(['order_id' => $orderId]);
|
||||
if ($rows === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO order_addresses (
|
||||
order_id, address_type, name, phone, email, street_name, street_number, city, zip_code, country,
|
||||
department, parcel_external_id, parcel_name, address_class, company_tax_number, company_name, payload_json
|
||||
) VALUES (
|
||||
:order_id, :address_type, :name, :phone, :email, :street_name, :street_number, :city, :zip_code, :country,
|
||||
:department, :parcel_external_id, :parcel_name, :address_class, :company_tax_number, :company_name, :payload_json
|
||||
)'
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$statement->execute([
|
||||
'order_id' => $orderId,
|
||||
'address_type' => (string) ($row['address_type'] ?? 'customer'),
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'phone' => $row['phone'] ?? null,
|
||||
'email' => $row['email'] ?? null,
|
||||
'street_name' => $row['street_name'] ?? null,
|
||||
'street_number' => $row['street_number'] ?? null,
|
||||
'city' => $row['city'] ?? null,
|
||||
'zip_code' => $row['zip_code'] ?? null,
|
||||
'country' => $row['country'] ?? null,
|
||||
'department' => $row['department'] ?? null,
|
||||
'parcel_external_id' => $row['parcel_external_id'] ?? null,
|
||||
'parcel_name' => $row['parcel_name'] ?? null,
|
||||
'address_class' => $row['address_class'] ?? null,
|
||||
'company_tax_number' => $row['company_tax_number'] ?? null,
|
||||
'company_name' => $row['company_name'] ?? null,
|
||||
'payload_json' => $this->encodeJson($row['payload_json'] ?? null),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
*/
|
||||
private function replaceItems(int $orderId, array $rows): void
|
||||
{
|
||||
$this->pdo->prepare('DELETE FROM order_items WHERE order_id = :order_id')->execute(['order_id' => $orderId]);
|
||||
if ($rows === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO order_items (
|
||||
order_id, source_item_id, external_item_id, ean, sku, original_name, original_code,
|
||||
original_price_with_tax, original_price_without_tax, media_url, quantity, tax_rate, item_status,
|
||||
unit, item_type, source_product_id, source_product_set_id, sort_order, payload_json
|
||||
) VALUES (
|
||||
:order_id, :source_item_id, :external_item_id, :ean, :sku, :original_name, :original_code,
|
||||
:original_price_with_tax, :original_price_without_tax, :media_url, :quantity, :tax_rate, :item_status,
|
||||
:unit, :item_type, :source_product_id, :source_product_set_id, :sort_order, :payload_json
|
||||
)'
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$statement->execute([
|
||||
'order_id' => $orderId,
|
||||
'source_item_id' => $row['source_item_id'] ?? null,
|
||||
'external_item_id' => $row['external_item_id'] ?? null,
|
||||
'ean' => $row['ean'] ?? null,
|
||||
'sku' => $row['sku'] ?? null,
|
||||
'original_name' => (string) ($row['original_name'] ?? ''),
|
||||
'original_code' => $row['original_code'] ?? null,
|
||||
'original_price_with_tax' => $row['original_price_with_tax'] ?? null,
|
||||
'original_price_without_tax' => $row['original_price_without_tax'] ?? null,
|
||||
'media_url' => $row['media_url'] ?? null,
|
||||
'quantity' => $row['quantity'] ?? 1,
|
||||
'tax_rate' => $row['tax_rate'] ?? null,
|
||||
'item_status' => $row['item_status'] ?? null,
|
||||
'unit' => $row['unit'] ?? null,
|
||||
'item_type' => (string) ($row['item_type'] ?? 'product'),
|
||||
'source_product_id' => $row['source_product_id'] ?? null,
|
||||
'source_product_set_id' => $row['source_product_set_id'] ?? null,
|
||||
'sort_order' => (int) ($row['sort_order'] ?? 0),
|
||||
'payload_json' => $this->encodeJson($row['payload_json'] ?? null),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
*/
|
||||
private function replacePayments(int $orderId, array $rows): void
|
||||
{
|
||||
$this->pdo->prepare('DELETE FROM order_payments WHERE order_id = :order_id')->execute(['order_id' => $orderId]);
|
||||
if ($rows === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO order_payments (
|
||||
order_id, source_payment_id, external_payment_id, payment_type_id, payment_date, amount, currency, comment, payload_json
|
||||
) VALUES (
|
||||
:order_id, :source_payment_id, :external_payment_id, :payment_type_id, :payment_date, :amount, :currency, :comment, :payload_json
|
||||
)'
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$statement->execute([
|
||||
'order_id' => $orderId,
|
||||
'source_payment_id' => $row['source_payment_id'] ?? null,
|
||||
'external_payment_id' => $row['external_payment_id'] ?? null,
|
||||
'payment_type_id' => (string) ($row['payment_type_id'] ?? 'unknown'),
|
||||
'payment_date' => $row['payment_date'] ?? null,
|
||||
'amount' => $row['amount'] ?? null,
|
||||
'currency' => $row['currency'] ?? null,
|
||||
'comment' => $row['comment'] ?? null,
|
||||
'payload_json' => $this->encodeJson($row['payload_json'] ?? null),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
*/
|
||||
private function replaceShipments(int $orderId, array $rows): void
|
||||
{
|
||||
$this->pdo->prepare('DELETE FROM order_shipments WHERE order_id = :order_id')->execute(['order_id' => $orderId]);
|
||||
if ($rows === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO order_shipments (
|
||||
order_id, source_shipment_id, external_shipment_id, tracking_number, carrier_provider_id, posted_at, media_uuid, payload_json
|
||||
) VALUES (
|
||||
:order_id, :source_shipment_id, :external_shipment_id, :tracking_number, :carrier_provider_id, :posted_at, :media_uuid, :payload_json
|
||||
)'
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$statement->execute([
|
||||
'order_id' => $orderId,
|
||||
'source_shipment_id' => $row['source_shipment_id'] ?? null,
|
||||
'external_shipment_id' => $row['external_shipment_id'] ?? null,
|
||||
'tracking_number' => (string) ($row['tracking_number'] ?? ''),
|
||||
'carrier_provider_id' => (string) ($row['carrier_provider_id'] ?? 'unknown'),
|
||||
'posted_at' => $row['posted_at'] ?? null,
|
||||
'media_uuid' => $row['media_uuid'] ?? null,
|
||||
'payload_json' => $this->encodeJson($row['payload_json'] ?? null),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
*/
|
||||
private function replaceNotes(int $orderId, array $rows): void
|
||||
{
|
||||
$this->pdo->prepare('DELETE FROM order_notes WHERE order_id = :order_id')->execute(['order_id' => $orderId]);
|
||||
if ($rows === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO order_notes (
|
||||
order_id, source_note_id, note_type, created_at_external, comment, payload_json
|
||||
) VALUES (
|
||||
:order_id, :source_note_id, :note_type, :created_at_external, :comment, :payload_json
|
||||
)'
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$statement->execute([
|
||||
'order_id' => $orderId,
|
||||
'source_note_id' => $row['source_note_id'] ?? null,
|
||||
'note_type' => (string) ($row['note_type'] ?? 'message'),
|
||||
'created_at_external' => $row['created_at_external'] ?? null,
|
||||
'comment' => (string) ($row['comment'] ?? ''),
|
||||
'payload_json' => $this->encodeJson($row['payload_json'] ?? null),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
*/
|
||||
private function replaceStatusHistory(int $orderId, array $rows): void
|
||||
{
|
||||
$this->pdo->prepare('DELETE FROM order_status_history WHERE order_id = :order_id')->execute(['order_id' => $orderId]);
|
||||
if ($rows === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO order_status_history (
|
||||
order_id, from_status_id, to_status_id, changed_at, change_source, comment, payload_json
|
||||
) VALUES (
|
||||
:order_id, :from_status_id, :to_status_id, :changed_at, :change_source, :comment, :payload_json
|
||||
)'
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$statement->execute([
|
||||
'order_id' => $orderId,
|
||||
'from_status_id' => $row['from_status_id'] ?? null,
|
||||
'to_status_id' => (string) ($row['to_status_id'] ?? ''),
|
||||
'changed_at' => $row['changed_at'] ?? date('Y-m-d H:i:s'),
|
||||
'change_source' => (string) ($row['change_source'] ?? 'import'),
|
||||
'comment' => $row['comment'] ?? null,
|
||||
'payload_json' => $this->encodeJson($row['payload_json'] ?? null),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function encodeJson(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
if (!is_array($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: null;
|
||||
}
|
||||
}
|
||||
@@ -39,11 +39,11 @@ final class OrdersController
|
||||
$result = $this->orders->paginate($filters);
|
||||
$totalPages = max(1, (int) ceil(((int) $result['total']) / max(1, (int) $result['per_page'])));
|
||||
$sourceOptions = $this->orders->sourceOptions();
|
||||
$statusOptions = $this->orders->statusOptions();
|
||||
$stats = $this->orders->quickStats();
|
||||
$statusCounts = $this->orders->statusCounts();
|
||||
$statusConfig = $this->orders->statusPanelConfig();
|
||||
$statusLabelMap = $this->statusLabelMap($statusConfig);
|
||||
$statusOptions = $this->buildStatusFilterOptions($this->orders->statusOptions(), $statusLabelMap);
|
||||
$statusPanel = $this->buildStatusPanel($statusConfig, $statusCounts, $filters['status'], $filters);
|
||||
|
||||
$tableRows = array_map(fn (array $row): array => $this->toTableRow($row, $statusLabelMap), (array) ($result['items'] ?? []));
|
||||
@@ -144,7 +144,7 @@ final class OrdersController
|
||||
$documents = is_array($details['documents'] ?? null) ? $details['documents'] : [];
|
||||
$notes = is_array($details['notes'] ?? null) ? $details['notes'] : [];
|
||||
$history = is_array($details['status_history'] ?? null) ? $details['status_history'] : [];
|
||||
$statusCode = (string) ($order['external_status_id'] ?? '');
|
||||
$statusCode = (string) (($order['effective_status_id'] ?? '') !== '' ? $order['effective_status_id'] : ($order['external_status_id'] ?? ''));
|
||||
$statusCounts = $this->orders->statusCounts();
|
||||
$statusConfig = $this->orders->statusPanelConfig();
|
||||
$statusLabelMap = $this->statusLabelMap($statusConfig);
|
||||
@@ -183,7 +183,7 @@ final class OrdersController
|
||||
$buyerName = trim((string) ($row['buyer_name'] ?? ''));
|
||||
$buyerEmail = trim((string) ($row['buyer_email'] ?? ''));
|
||||
$buyerCity = trim((string) ($row['buyer_city'] ?? ''));
|
||||
$status = trim((string) ($row['external_status_id'] ?? ''));
|
||||
$status = trim((string) (($row['effective_status_id'] ?? '') !== '' ? $row['effective_status_id'] : ($row['external_status_id'] ?? '')));
|
||||
$currency = trim((string) ($row['currency'] ?? ''));
|
||||
$totalWithTax = $row['total_with_tax'] !== null ? number_format((float) $row['total_with_tax'], 2, '.', ' ') : '-';
|
||||
$totalPaid = $row['total_paid'] !== null ? number_format((float) $row['total_paid'], 2, '.', ' ') : '-';
|
||||
@@ -211,7 +211,7 @@ final class OrdersController
|
||||
. '</div>'
|
||||
. '</div>',
|
||||
'status_badges' => '<div class="orders-status-wrap">'
|
||||
. $this->statusBadge($this->statusLabel($status, $statusLabelMap))
|
||||
. $this->statusBadge($status, $this->statusLabel($status, $statusLabelMap))
|
||||
. '</div>',
|
||||
'products' => $this->productsHtml($itemsPreview, $itemsCount, $itemsQty),
|
||||
'totals' => '<div class="orders-money">'
|
||||
@@ -227,17 +227,18 @@ final class OrdersController
|
||||
];
|
||||
}
|
||||
|
||||
private function statusBadge(string $status): string
|
||||
private function statusBadge(string $statusCode, string $statusLabel): string
|
||||
{
|
||||
$label = $status !== '' ? $status : '-';
|
||||
$label = $statusLabel !== '' ? $statusLabel : '-';
|
||||
$code = strtolower(trim($statusCode));
|
||||
$class = 'is-neutral';
|
||||
if (in_array($status, ['shipped', 'delivered'], true)) {
|
||||
if (in_array($code, ['shipped', 'delivered'], true)) {
|
||||
$class = 'is-success';
|
||||
} elseif (in_array($status, ['cancelled', 'returned'], true)) {
|
||||
} elseif (in_array($code, ['cancelled', 'returned'], true)) {
|
||||
$class = 'is-danger';
|
||||
} elseif (in_array($status, ['new', 'confirmed'], true)) {
|
||||
} elseif (in_array($code, ['new', 'confirmed'], true)) {
|
||||
$class = 'is-info';
|
||||
} elseif (in_array($status, ['processing', 'packed', 'paid'], true)) {
|
||||
} elseif (in_array($code, ['processing', 'packed', 'paid'], true)) {
|
||||
$class = 'is-warn';
|
||||
}
|
||||
|
||||
@@ -255,7 +256,8 @@ final class OrdersController
|
||||
return (string) $statusLabelMap[$key];
|
||||
}
|
||||
|
||||
return ucfirst($statusCode);
|
||||
$normalized = str_replace(['_', '-'], ' ', $key);
|
||||
return ucfirst($normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -415,6 +417,26 @@ final class OrdersController
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $statusCodes
|
||||
* @param array<string, string> $statusLabelMap
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildStatusFilterOptions(array $statusCodes, array $statusLabelMap): array
|
||||
{
|
||||
$options = [];
|
||||
foreach ($statusCodes as $code => $value) {
|
||||
$rawCode = trim((string) ($code !== '' ? $code : $value));
|
||||
if ($rawCode === '') {
|
||||
continue;
|
||||
}
|
||||
$normalizedCode = strtolower($rawCode);
|
||||
$options[$normalizedCode] = $this->statusLabel($normalizedCode, $statusLabelMap);
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $itemsPreview
|
||||
*/
|
||||
|
||||
@@ -8,6 +8,8 @@ use Throwable;
|
||||
|
||||
final class OrdersRepository
|
||||
{
|
||||
private ?bool $supportsMappedMedia = null;
|
||||
|
||||
public function __construct(private readonly PDO $pdo)
|
||||
{
|
||||
}
|
||||
@@ -24,6 +26,7 @@ final class OrdersRepository
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
$effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
|
||||
|
||||
$search = trim((string) ($filters['search'] ?? ''));
|
||||
if ($search !== '') {
|
||||
@@ -45,7 +48,7 @@ final class OrdersRepository
|
||||
|
||||
$status = trim((string) ($filters['status'] ?? ''));
|
||||
if ($status !== '') {
|
||||
$where[] = 'o.external_status_id = :status';
|
||||
$where[] = $effectiveStatusSql . ' = :status';
|
||||
$params['status'] = $status;
|
||||
}
|
||||
|
||||
@@ -86,7 +89,8 @@ final class OrdersRepository
|
||||
|
||||
try {
|
||||
$countSql = 'SELECT COUNT(*) FROM orders o '
|
||||
. 'LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer"'
|
||||
. 'LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer" '
|
||||
. 'LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code'
|
||||
. $whereSql;
|
||||
$countStmt = $this->pdo->prepare($countSql);
|
||||
$countStmt->execute($params);
|
||||
@@ -98,6 +102,7 @@ final class OrdersRepository
|
||||
o.source_order_id,
|
||||
o.external_order_id,
|
||||
o.external_status_id,
|
||||
' . $effectiveStatusSql . ' AS effective_status_id,
|
||||
o.payment_status,
|
||||
o.currency,
|
||||
o.total_with_tax,
|
||||
@@ -115,7 +120,8 @@ final class OrdersRepository
|
||||
(SELECT COUNT(*) FROM order_shipments sh WHERE sh.order_id = o.id) AS shipments_count,
|
||||
(SELECT COUNT(*) FROM order_documents od WHERE od.order_id = o.id) AS documents_count
|
||||
FROM orders o
|
||||
LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer"'
|
||||
LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer"
|
||||
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code'
|
||||
. $whereSql
|
||||
. ' ORDER BY ' . $sortColumn . ' ' . $sortDir
|
||||
. ' LIMIT :limit OFFSET :offset';
|
||||
@@ -150,6 +156,7 @@ final class OrdersRepository
|
||||
'source_order_id' => (string) ($row['source_order_id'] ?? ''),
|
||||
'external_order_id' => (string) ($row['external_order_id'] ?? ''),
|
||||
'external_status_id' => (string) ($row['external_status_id'] ?? ''),
|
||||
'effective_status_id' => (string) ($row['effective_status_id'] ?? ''),
|
||||
'payment_status' => isset($row['payment_status']) ? (int) $row['payment_status'] : null,
|
||||
'currency' => (string) ($row['currency'] ?? ''),
|
||||
'total_with_tax' => $row['total_with_tax'] !== null ? (float) $row['total_with_tax'] : null,
|
||||
@@ -191,7 +198,15 @@ final class OrdersRepository
|
||||
public function statusOptions(): array
|
||||
{
|
||||
try {
|
||||
$rows = $this->pdo->query('SELECT DISTINCT external_status_id FROM orders WHERE external_status_id IS NOT NULL AND external_status_id <> "" ORDER BY external_status_id ASC')->fetchAll(PDO::FETCH_COLUMN);
|
||||
$effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
|
||||
$rows = $this->pdo->query(
|
||||
'SELECT DISTINCT ' . $effectiveStatusSql . ' AS effective_status_id
|
||||
FROM orders o
|
||||
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code
|
||||
WHERE ' . $effectiveStatusSql . ' IS NOT NULL
|
||||
AND ' . $effectiveStatusSql . ' <> ""
|
||||
ORDER BY effective_status_id ASC'
|
||||
)->fetchAll(PDO::FETCH_COLUMN);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
@@ -245,11 +260,13 @@ final class OrdersRepository
|
||||
public function quickStats(): array
|
||||
{
|
||||
try {
|
||||
$effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
|
||||
$row = $this->pdo->query('SELECT
|
||||
COUNT(*) AS all_count,
|
||||
SUM(CASE WHEN payment_status = 2 THEN 1 ELSE 0 END) AS paid_count,
|
||||
SUM(CASE WHEN external_status_id IN ("shipped", "delivered", "returned") THEN 1 ELSE 0 END) AS shipped_count
|
||||
FROM orders')->fetch(PDO::FETCH_ASSOC);
|
||||
SUM(CASE WHEN ' . $effectiveStatusSql . ' IN ("shipped", "delivered", "returned") THEN 1 ELSE 0 END) AS shipped_count
|
||||
FROM orders o
|
||||
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code')->fetch(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable) {
|
||||
return [
|
||||
'all' => 0,
|
||||
@@ -279,7 +296,13 @@ final class OrdersRepository
|
||||
public function statusCounts(): array
|
||||
{
|
||||
try {
|
||||
$rows = $this->pdo->query('SELECT external_status_id, COUNT(*) AS cnt FROM orders GROUP BY external_status_id')->fetchAll(PDO::FETCH_ASSOC);
|
||||
$effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
|
||||
$rows = $this->pdo->query(
|
||||
'SELECT ' . $effectiveStatusSql . ' AS effective_status_id, COUNT(*) AS cnt
|
||||
FROM orders o
|
||||
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code
|
||||
GROUP BY effective_status_id'
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
@@ -290,7 +313,7 @@ final class OrdersRepository
|
||||
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$key = trim((string) ($row['external_status_id'] ?? ''));
|
||||
$key = trim((string) ($row['effective_status_id'] ?? ''));
|
||||
if ($key === '') {
|
||||
$key = '_empty';
|
||||
}
|
||||
@@ -366,7 +389,14 @@ final class OrdersRepository
|
||||
}
|
||||
|
||||
try {
|
||||
$orderStmt = $this->pdo->prepare('SELECT * FROM orders WHERE id = :id LIMIT 1');
|
||||
$effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
|
||||
$orderStmt = $this->pdo->prepare(
|
||||
'SELECT o.*, ' . $effectiveStatusSql . ' AS effective_status_id
|
||||
FROM orders o
|
||||
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code
|
||||
WHERE o.id = :id
|
||||
LIMIT 1'
|
||||
);
|
||||
$orderStmt->execute(['id' => $orderId]);
|
||||
$order = $orderStmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!is_array($order)) {
|
||||
@@ -380,12 +410,25 @@ final class OrdersRepository
|
||||
$addresses = [];
|
||||
}
|
||||
|
||||
$itemsStmt = $this->pdo->prepare('SELECT * FROM order_items WHERE order_id = :order_id ORDER BY sort_order ASC, id ASC');
|
||||
$itemsMediaSql = $this->resolvedMediaUrlSql('oi');
|
||||
$itemsStmt = $this->pdo->prepare('SELECT oi.*, ' . $itemsMediaSql . ' AS resolved_media_url
|
||||
FROM order_items oi
|
||||
WHERE oi.order_id = :order_id
|
||||
ORDER BY oi.sort_order ASC, oi.id ASC');
|
||||
$itemsStmt->execute(['order_id' => $orderId]);
|
||||
$items = $itemsStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (!is_array($items)) {
|
||||
$items = [];
|
||||
}
|
||||
$items = array_map(static function (array $row): array {
|
||||
$resolvedMediaUrl = trim((string) ($row['resolved_media_url'] ?? ''));
|
||||
if ($resolvedMediaUrl !== '') {
|
||||
$row['media_url'] = $resolvedMediaUrl;
|
||||
}
|
||||
unset($row['resolved_media_url']);
|
||||
|
||||
return $row;
|
||||
}, $items);
|
||||
|
||||
$paymentsStmt = $this->pdo->prepare('SELECT * FROM order_payments WHERE order_id = :order_id ORDER BY payment_date ASC, id ASC');
|
||||
$paymentsStmt->execute(['order_id' => $orderId]);
|
||||
@@ -457,10 +500,11 @@ final class OrdersRepository
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($cleanIds), '?'));
|
||||
try {
|
||||
$sql = 'SELECT order_id, original_name, quantity, COALESCE(media_url, "") AS media_url, sort_order, id
|
||||
FROM order_items
|
||||
WHERE order_id IN (' . $placeholders . ')
|
||||
ORDER BY order_id ASC, sort_order ASC, id ASC';
|
||||
$resolvedMediaSql = $this->resolvedMediaUrlSql('oi');
|
||||
$sql = 'SELECT oi.order_id, oi.original_name, oi.quantity, ' . $resolvedMediaSql . ' AS media_url, oi.sort_order, oi.id
|
||||
FROM order_items oi
|
||||
WHERE oi.order_id IN (' . $placeholders . ')
|
||||
ORDER BY oi.order_id ASC, oi.sort_order ASC, oi.id ASC';
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
foreach ($cleanIds as $index => $orderId) {
|
||||
$stmt->bindValue($index + 1, $orderId, PDO::PARAM_INT);
|
||||
@@ -496,6 +540,88 @@ final class OrdersRepository
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function effectiveStatusSql(string $orderAlias, string $mappingAlias): string
|
||||
{
|
||||
return 'CASE
|
||||
WHEN ' . $orderAlias . '.source = "allegro"
|
||||
AND ' . $mappingAlias . '.orderpro_status_code IS NOT NULL
|
||||
AND ' . $mappingAlias . '.orderpro_status_code <> ""
|
||||
THEN ' . $mappingAlias . '.orderpro_status_code
|
||||
ELSE ' . $orderAlias . '.external_status_id
|
||||
END';
|
||||
}
|
||||
|
||||
private function resolvedMediaUrlSql(string $itemAlias): string
|
||||
{
|
||||
if (!$this->canResolveMappedMedia()) {
|
||||
return 'COALESCE(NULLIF(TRIM(' . $itemAlias . '.media_url), ""), "")';
|
||||
}
|
||||
|
||||
return 'COALESCE(
|
||||
NULLIF(TRIM(' . $itemAlias . '.media_url), ""),
|
||||
(
|
||||
SELECT NULLIF(TRIM(pi.storage_path), "")
|
||||
FROM product_channel_map pcm
|
||||
INNER JOIN sales_channels sc ON sc.id = pcm.channel_id
|
||||
INNER JOIN product_images pi ON pi.product_id = pcm.product_id
|
||||
WHERE LOWER(sc.code) = "allegro"
|
||||
AND (
|
||||
pcm.external_product_id = ' . $itemAlias . '.external_item_id
|
||||
OR pcm.external_product_id = ' . $itemAlias . '.source_product_id
|
||||
)
|
||||
ORDER BY pi.is_main DESC, pi.sort_order ASC, pi.id ASC
|
||||
LIMIT 1
|
||||
),
|
||||
""
|
||||
)';
|
||||
}
|
||||
|
||||
private function canResolveMappedMedia(): bool
|
||||
{
|
||||
if ($this->supportsMappedMedia !== null) {
|
||||
return $this->supportsMappedMedia;
|
||||
}
|
||||
|
||||
try {
|
||||
$requiredColumns = [
|
||||
['table' => 'product_channel_map', 'column' => 'product_id'],
|
||||
['table' => 'product_channel_map', 'column' => 'channel_id'],
|
||||
['table' => 'product_channel_map', 'column' => 'external_product_id'],
|
||||
['table' => 'sales_channels', 'column' => 'id'],
|
||||
['table' => 'sales_channels', 'column' => 'code'],
|
||||
['table' => 'product_images', 'column' => 'id'],
|
||||
['table' => 'product_images', 'column' => 'product_id'],
|
||||
['table' => 'product_images', 'column' => 'storage_path'],
|
||||
['table' => 'product_images', 'column' => 'sort_order'],
|
||||
['table' => 'product_images', 'column' => 'is_main'],
|
||||
];
|
||||
|
||||
$pairsSql = [];
|
||||
$params = [];
|
||||
foreach ($requiredColumns as $index => $required) {
|
||||
$tableParam = ':table_' . $index;
|
||||
$columnParam = ':column_' . $index;
|
||||
$pairsSql[] = '(TABLE_NAME = ' . $tableParam . ' AND COLUMN_NAME = ' . $columnParam . ')';
|
||||
$params['table_' . $index] = $required['table'];
|
||||
$params['column_' . $index] = $required['column'];
|
||||
}
|
||||
|
||||
$sql = 'SELECT COUNT(*) AS cnt
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND (' . implode(' OR ', $pairsSql) . ')';
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$count = (int) $stmt->fetchColumn();
|
||||
|
||||
$this->supportsMappedMedia = ($count === count($requiredColumns));
|
||||
} catch (Throwable) {
|
||||
$this->supportsMappedMedia = false;
|
||||
}
|
||||
|
||||
return $this->supportsMappedMedia;
|
||||
}
|
||||
|
||||
private function normalizeColorHex(string $value): string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
|
||||
108
src/Modules/Settings/AllegroApiClient.php
Normal file
108
src/Modules/Settings/AllegroApiClient.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class AllegroApiClient
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getCheckoutForm(string $environment, string $accessToken, string $checkoutFormId): array
|
||||
{
|
||||
$safeId = rawurlencode(trim($checkoutFormId));
|
||||
if ($safeId === '') {
|
||||
throw new RuntimeException('Brak ID zamowienia Allegro do pobrania.');
|
||||
}
|
||||
|
||||
$url = rtrim($this->apiBaseUrl($environment), '/') . '/order/checkout-forms/' . $safeId;
|
||||
return $this->requestJson($url, $accessToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function listCheckoutForms(string $environment, string $accessToken, int $limit, int $offset): array
|
||||
{
|
||||
$safeLimit = max(1, min(100, $limit));
|
||||
$safeOffset = max(0, $offset);
|
||||
$query = http_build_query([
|
||||
'limit' => $safeLimit,
|
||||
'offset' => $safeOffset,
|
||||
'sort' => '-updatedAt',
|
||||
]);
|
||||
|
||||
$url = rtrim($this->apiBaseUrl($environment), '/') . '/order/checkout-forms?' . $query;
|
||||
return $this->requestJson($url, $accessToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getProductOffer(string $environment, string $accessToken, string $offerId): array
|
||||
{
|
||||
$safeId = rawurlencode(trim($offerId));
|
||||
if ($safeId === '') {
|
||||
throw new RuntimeException('Brak ID oferty Allegro do pobrania.');
|
||||
}
|
||||
|
||||
$url = rtrim($this->apiBaseUrl($environment), '/') . '/sale/product-offers/' . $safeId;
|
||||
return $this->requestJson($url, $accessToken);
|
||||
}
|
||||
|
||||
private function apiBaseUrl(string $environment): string
|
||||
{
|
||||
return trim(strtolower($environment)) === 'production'
|
||||
? 'https://api.allegro.pl'
|
||||
: 'https://api.allegro.pl.allegrosandbox.pl';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function requestJson(string $url, string $accessToken): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
throw new RuntimeException('Nie udalo sie zainicjowac polaczenia z API Allegro.');
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPGET => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/vnd.allegro.public.v1+json',
|
||||
'Authorization: Bearer ' . $accessToken,
|
||||
],
|
||||
]);
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
$ch = null;
|
||||
|
||||
if ($responseBody === false) {
|
||||
throw new RuntimeException('Blad polaczenia z API Allegro: ' . $curlError);
|
||||
}
|
||||
|
||||
$json = json_decode((string) $responseBody, true);
|
||||
if (!is_array($json)) {
|
||||
throw new RuntimeException('Nieprawidlowy JSON odpowiedzi API Allegro.');
|
||||
}
|
||||
|
||||
if ($httpCode === 401) {
|
||||
throw new RuntimeException('ALLEGRO_HTTP_401');
|
||||
}
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
$message = trim((string) ($json['message'] ?? 'Blad API Allegro.'));
|
||||
throw new RuntimeException('API Allegro HTTP ' . $httpCode . ': ' . $message);
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
}
|
||||
703
src/Modules/Settings/AllegroIntegrationController.php
Normal file
703
src/Modules/Settings/AllegroIntegrationController.php
Normal file
@@ -0,0 +1,703 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use App\Core\Http\Request;
|
||||
use App\Core\Http\Response;
|
||||
use App\Core\I18n\Translator;
|
||||
use App\Core\Security\Csrf;
|
||||
use App\Core\Support\Flash;
|
||||
use App\Core\View\Template;
|
||||
use App\Modules\Auth\AuthService;
|
||||
use App\Modules\Cron\CronRepository;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class AllegroIntegrationController
|
||||
{
|
||||
private const OAUTH_STATE_SESSION_KEY = 'allegro_oauth_state';
|
||||
private const ORDERS_IMPORT_JOB_TYPE = 'allegro_orders_import';
|
||||
private const STATUS_SYNC_JOB_TYPE = 'allegro_status_sync';
|
||||
private const ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS = 300;
|
||||
private const ORDERS_IMPORT_DEFAULT_PRIORITY = 20;
|
||||
private const ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS = 3;
|
||||
private const STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO = 'allegro_to_orderpro';
|
||||
private const STATUS_SYNC_DIRECTION_ORDERPRO_TO_ALLEGRO = 'orderpro_to_allegro';
|
||||
private const STATUS_SYNC_DEFAULT_INTERVAL_MINUTES = 15;
|
||||
private const ORDERS_IMPORT_DEFAULT_PAYLOAD = [
|
||||
'max_pages' => 5,
|
||||
'page_limit' => 50,
|
||||
'max_orders' => 200,
|
||||
];
|
||||
private const OAUTH_SCOPES = [
|
||||
AllegroOAuthClient::ORDERS_READ_SCOPE,
|
||||
AllegroOAuthClient::SALE_OFFERS_READ_SCOPE,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly Template $template,
|
||||
private readonly Translator $translator,
|
||||
private readonly AuthService $auth,
|
||||
private readonly AllegroIntegrationRepository $repository,
|
||||
private readonly AllegroStatusMappingRepository $statusMappings,
|
||||
private readonly OrderStatusRepository $orderStatuses,
|
||||
private readonly CronRepository $cronRepository,
|
||||
private readonly AllegroOAuthClient $oauthClient,
|
||||
private readonly AllegroOrderImportService $orderImportService,
|
||||
private readonly AllegroStatusDiscoveryService $statusDiscoveryService,
|
||||
private readonly string $appUrl
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$settings = $this->repository->getSettings();
|
||||
$tab = trim((string) $request->input('tab', 'integration'));
|
||||
if (!in_array($tab, ['integration', 'statuses', 'settings'], true)) {
|
||||
$tab = 'integration';
|
||||
}
|
||||
$defaultRedirectUri = $this->defaultRedirectUri();
|
||||
|
||||
if (trim((string) ($settings['redirect_uri'] ?? '')) === '') {
|
||||
$settings['redirect_uri'] = $defaultRedirectUri;
|
||||
}
|
||||
$importIntervalSeconds = $this->currentImportIntervalSeconds();
|
||||
$statusSyncDirection = $this->currentStatusSyncDirection();
|
||||
$statusSyncIntervalMinutes = $this->currentStatusSyncIntervalMinutes();
|
||||
|
||||
$html = $this->template->render('settings/allegro', [
|
||||
'title' => $this->translator->get('settings.allegro.title'),
|
||||
'activeMenu' => 'settings',
|
||||
'activeSettings' => 'allegro',
|
||||
'user' => $this->auth->user(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
'settings' => $settings,
|
||||
'activeTab' => $tab,
|
||||
'importIntervalSeconds' => $importIntervalSeconds,
|
||||
'statusSyncDirection' => $statusSyncDirection,
|
||||
'statusSyncIntervalMinutes' => $statusSyncIntervalMinutes,
|
||||
'statusMappings' => $this->statusMappings->listMappings(),
|
||||
'orderproStatuses' => $this->orderStatuses->listStatuses(),
|
||||
'defaultRedirectUri' => $defaultRedirectUri,
|
||||
'errorMessage' => (string) Flash::get('settings_error', ''),
|
||||
'successMessage' => (string) Flash::get('settings_success', ''),
|
||||
'warningMessage' => (string) Flash::get('settings_warning', ''),
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
$csrfError = $this->validateCsrf((string) $request->input('_token', ''));
|
||||
if ($csrfError !== null) {
|
||||
return $csrfError;
|
||||
}
|
||||
|
||||
$environment = trim((string) $request->input('environment', 'sandbox'));
|
||||
if (!in_array($environment, ['sandbox', 'production'], true)) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.validation.environment_invalid'));
|
||||
return Response::redirect('/settings/integrations/allegro');
|
||||
}
|
||||
|
||||
$clientId = trim((string) $request->input('client_id', ''));
|
||||
if ($clientId !== '' && mb_strlen($clientId) > 128) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.validation.client_id_too_long'));
|
||||
return Response::redirect('/settings/integrations/allegro');
|
||||
}
|
||||
|
||||
$redirectUriInput = trim((string) $request->input('redirect_uri', ''));
|
||||
$redirectUri = $redirectUriInput !== '' ? $redirectUriInput : $this->defaultRedirectUri();
|
||||
if (!$this->isValidHttpUrl($redirectUri)) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.validation.redirect_uri_invalid'));
|
||||
return Response::redirect('/settings/integrations/allegro');
|
||||
}
|
||||
|
||||
$ordersFetchStartDate = trim((string) $request->input('orders_fetch_start_date', ''));
|
||||
if ($ordersFetchStartDate !== '' && !$this->isValidDate($ordersFetchStartDate)) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.validation.orders_fetch_start_date_invalid'));
|
||||
return Response::redirect('/settings/integrations/allegro');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->repository->saveSettings([
|
||||
'environment' => $environment,
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => trim((string) $request->input('client_secret', '')),
|
||||
'redirect_uri' => $redirectUri,
|
||||
'orders_fetch_enabled' => (string) $request->input('orders_fetch_enabled', '0') === '1',
|
||||
'orders_fetch_start_date' => $ordersFetchStartDate,
|
||||
]);
|
||||
Flash::set('settings_success', $this->translator->get('settings.allegro.flash.saved'));
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set(
|
||||
'settings_error',
|
||||
$this->translator->get('settings.allegro.flash.save_failed') . ' ' . $exception->getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/integrations/allegro');
|
||||
}
|
||||
|
||||
public function saveImportSettings(Request $request): Response
|
||||
{
|
||||
$csrfError = $this->validateCsrf((string) $request->input('_token', ''));
|
||||
if ($csrfError !== null) {
|
||||
return $csrfError;
|
||||
}
|
||||
|
||||
$intervalMinutesRaw = (int) $request->input('orders_import_interval_minutes', 5);
|
||||
$intervalMinutes = max(1, min(1440, $intervalMinutesRaw));
|
||||
if ($intervalMinutesRaw !== $intervalMinutes) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.validation.orders_import_interval_invalid'));
|
||||
return Response::redirect('/settings/integrations/allegro?tab=settings');
|
||||
}
|
||||
|
||||
$statusSyncDirection = trim((string) $request->input(
|
||||
'status_sync_direction',
|
||||
self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO
|
||||
));
|
||||
if (!in_array($statusSyncDirection, $this->allowedStatusSyncDirections(), true)) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.validation.status_sync_direction_invalid'));
|
||||
return Response::redirect('/settings/integrations/allegro?tab=settings');
|
||||
}
|
||||
|
||||
$statusSyncIntervalRaw = (int) $request->input(
|
||||
'status_sync_interval_minutes',
|
||||
self::STATUS_SYNC_DEFAULT_INTERVAL_MINUTES
|
||||
);
|
||||
$statusSyncInterval = max(1, min(1440, $statusSyncIntervalRaw));
|
||||
if ($statusSyncIntervalRaw !== $statusSyncInterval) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.validation.status_sync_interval_invalid'));
|
||||
return Response::redirect('/settings/integrations/allegro?tab=settings');
|
||||
}
|
||||
|
||||
$existing = $this->findImportSchedule();
|
||||
$priority = (int) ($existing['priority'] ?? self::ORDERS_IMPORT_DEFAULT_PRIORITY);
|
||||
$maxAttempts = (int) ($existing['max_attempts'] ?? self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS);
|
||||
$payload = is_array($existing['payload'] ?? null)
|
||||
? (array) $existing['payload']
|
||||
: self::ORDERS_IMPORT_DEFAULT_PAYLOAD;
|
||||
$enabled = array_key_exists('enabled', $existing)
|
||||
? (bool) $existing['enabled']
|
||||
: true;
|
||||
$statusSchedule = $this->findStatusSyncSchedule();
|
||||
$statusPriority = (int) ($statusSchedule['priority'] ?? self::ORDERS_IMPORT_DEFAULT_PRIORITY);
|
||||
$statusMaxAttempts = (int) ($statusSchedule['max_attempts'] ?? self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS);
|
||||
$statusEnabled = array_key_exists('enabled', $statusSchedule)
|
||||
? (bool) $statusSchedule['enabled']
|
||||
: true;
|
||||
|
||||
try {
|
||||
$this->cronRepository->upsertSchedule(
|
||||
self::ORDERS_IMPORT_JOB_TYPE,
|
||||
$intervalMinutes * 60,
|
||||
$priority,
|
||||
$maxAttempts,
|
||||
$payload,
|
||||
$enabled
|
||||
);
|
||||
$this->cronRepository->upsertSchedule(
|
||||
self::STATUS_SYNC_JOB_TYPE,
|
||||
$statusSyncInterval * 60,
|
||||
$statusPriority,
|
||||
$statusMaxAttempts,
|
||||
null,
|
||||
$statusEnabled
|
||||
);
|
||||
$this->cronRepository->upsertSetting('allegro_status_sync_direction', $statusSyncDirection);
|
||||
$this->cronRepository->upsertSetting('allegro_status_sync_interval_minutes', (string) $statusSyncInterval);
|
||||
Flash::set('settings_success', $this->translator->get('settings.allegro.flash.import_settings_saved'));
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set(
|
||||
'settings_error',
|
||||
$this->translator->get('settings.allegro.flash.import_settings_save_failed') . ' ' . $exception->getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/integrations/allegro?tab=settings');
|
||||
}
|
||||
|
||||
public function saveStatusMapping(Request $request): Response
|
||||
{
|
||||
$csrfError = $this->validateCsrf((string) $request->input('_token', ''));
|
||||
if ($csrfError !== null) {
|
||||
return $csrfError;
|
||||
}
|
||||
|
||||
$allegroStatusCode = strtolower(trim((string) $request->input('allegro_status_code', '')));
|
||||
$orderproStatusCode = strtolower(trim((string) $request->input('orderpro_status_code', '')));
|
||||
$allegroStatusName = trim((string) $request->input('allegro_status_name', ''));
|
||||
|
||||
if ($allegroStatusCode === '') {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.allegro_status_required'));
|
||||
return Response::redirect('/settings/integrations/allegro?tab=statuses');
|
||||
}
|
||||
|
||||
if ($orderproStatusCode === '') {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.orderpro_status_required'));
|
||||
return Response::redirect('/settings/integrations/allegro?tab=statuses');
|
||||
}
|
||||
|
||||
if (!$this->orderStatusCodeExists($orderproStatusCode)) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.orderpro_status_not_found'));
|
||||
return Response::redirect('/settings/integrations/allegro?tab=statuses');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->statusMappings->upsertMapping($allegroStatusCode, $allegroStatusName !== '' ? $allegroStatusName : null, $orderproStatusCode);
|
||||
Flash::set('settings_success', $this->translator->get('settings.allegro.statuses.flash.saved'));
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.save_failed') . ' ' . $exception->getMessage());
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/integrations/allegro?tab=statuses');
|
||||
}
|
||||
|
||||
public function saveStatusMappingsBulk(Request $request): Response
|
||||
{
|
||||
$csrfError = $this->validateCsrf((string) $request->input('_token', ''));
|
||||
if ($csrfError !== null) {
|
||||
return $csrfError;
|
||||
}
|
||||
|
||||
$codes = $request->input('allegro_status_code', []);
|
||||
$names = $request->input('allegro_status_name', []);
|
||||
$selectedOrderproCodes = $request->input('orderpro_status_code', []);
|
||||
if (!is_array($codes) || !is_array($names) || !is_array($selectedOrderproCodes)) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.save_failed'));
|
||||
return Response::redirect('/settings/integrations/allegro?tab=statuses');
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($codes as $index => $rawCode) {
|
||||
$allegroStatusCode = strtolower(trim((string) $rawCode));
|
||||
if ($allegroStatusCode === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$allegroStatusName = trim((string) ($names[$index] ?? ''));
|
||||
$orderproStatusCodeRaw = strtolower(trim((string) ($selectedOrderproCodes[$index] ?? '')));
|
||||
$orderproStatusCode = $orderproStatusCodeRaw !== '' ? $orderproStatusCodeRaw : null;
|
||||
|
||||
if ($orderproStatusCode !== null && !$this->orderStatusCodeExists($orderproStatusCode)) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.orderpro_status_not_found'));
|
||||
return Response::redirect('/settings/integrations/allegro?tab=statuses');
|
||||
}
|
||||
|
||||
$this->statusMappings->upsertMapping(
|
||||
$allegroStatusCode,
|
||||
$allegroStatusName !== '' ? $allegroStatusName : null,
|
||||
$orderproStatusCode
|
||||
);
|
||||
}
|
||||
|
||||
Flash::set('settings_success', $this->translator->get('settings.allegro.statuses.flash.saved_bulk'));
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.save_failed') . ' ' . $exception->getMessage());
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/integrations/allegro?tab=statuses');
|
||||
}
|
||||
|
||||
public function deleteStatusMapping(Request $request): Response
|
||||
{
|
||||
$csrfError = $this->validateCsrf((string) $request->input('_token', ''));
|
||||
if ($csrfError !== null) {
|
||||
return $csrfError;
|
||||
}
|
||||
|
||||
$mappingId = max(0, (int) $request->input('mapping_id', 0));
|
||||
if ($mappingId <= 0) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.mapping_not_found'));
|
||||
return Response::redirect('/settings/integrations/allegro?tab=statuses');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->statusMappings->deleteMappingById($mappingId);
|
||||
Flash::set('settings_success', $this->translator->get('settings.allegro.statuses.flash.deleted'));
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.delete_failed') . ' ' . $exception->getMessage());
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/integrations/allegro?tab=statuses');
|
||||
}
|
||||
|
||||
public function syncStatusesFromAllegro(Request $request): Response
|
||||
{
|
||||
$csrfError = $this->validateCsrf((string) $request->input('_token', ''));
|
||||
if ($csrfError !== null) {
|
||||
return $csrfError;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->statusDiscoveryService->discoverAndStoreStatuses(5, 100);
|
||||
Flash::set(
|
||||
'settings_success',
|
||||
$this->translator->get('settings.allegro.statuses.flash.sync_ok', [
|
||||
'discovered' => (string) ((int) ($result['discovered'] ?? 0)),
|
||||
'samples' => (string) ((int) ($result['samples'] ?? 0)),
|
||||
])
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.sync_failed') . ' ' . $exception->getMessage());
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/integrations/allegro?tab=statuses');
|
||||
}
|
||||
|
||||
public function startOAuth(Request $request): Response
|
||||
{
|
||||
$csrfError = $this->validateCsrf((string) $request->input('_token', ''));
|
||||
if ($csrfError !== null) {
|
||||
return $csrfError;
|
||||
}
|
||||
|
||||
try {
|
||||
$credentials = $this->requireOAuthCredentials();
|
||||
$state = bin2hex(random_bytes(24));
|
||||
$_SESSION[self::OAUTH_STATE_SESSION_KEY] = $state;
|
||||
|
||||
$url = $this->oauthClient->buildAuthorizeUrl(
|
||||
(string) $credentials['environment'],
|
||||
(string) $credentials['client_id'],
|
||||
(string) $credentials['redirect_uri'],
|
||||
$state,
|
||||
self::OAUTH_SCOPES
|
||||
);
|
||||
|
||||
return Response::redirect($url);
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set('settings_error', $exception->getMessage());
|
||||
return Response::redirect('/settings/integrations/allegro');
|
||||
}
|
||||
}
|
||||
|
||||
public function oauthCallback(Request $request): Response
|
||||
{
|
||||
$error = trim((string) $request->input('error', ''));
|
||||
if ($error !== '') {
|
||||
$description = trim((string) $request->input('error_description', ''));
|
||||
$message = $this->translator->get('settings.allegro.flash.oauth_failed');
|
||||
if ($description !== '') {
|
||||
$message .= ' ' . $description;
|
||||
}
|
||||
Flash::set('settings_error', $message);
|
||||
return Response::redirect('/settings/integrations/allegro');
|
||||
}
|
||||
|
||||
$state = trim((string) $request->input('state', ''));
|
||||
$expectedState = trim((string) ($_SESSION[self::OAUTH_STATE_SESSION_KEY] ?? ''));
|
||||
unset($_SESSION[self::OAUTH_STATE_SESSION_KEY]);
|
||||
if ($state === '' || $expectedState === '' || !hash_equals($expectedState, $state)) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.flash.oauth_state_invalid'));
|
||||
return Response::redirect('/settings/integrations/allegro');
|
||||
}
|
||||
|
||||
$authorizationCode = trim((string) $request->input('code', ''));
|
||||
if ($authorizationCode === '') {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.flash.oauth_code_missing'));
|
||||
return Response::redirect('/settings/integrations/allegro');
|
||||
}
|
||||
|
||||
try {
|
||||
$credentials = $this->requireOAuthCredentials();
|
||||
$token = $this->oauthClient->exchangeAuthorizationCode(
|
||||
(string) $credentials['environment'],
|
||||
(string) $credentials['client_id'],
|
||||
(string) $credentials['client_secret'],
|
||||
(string) $credentials['redirect_uri'],
|
||||
$authorizationCode
|
||||
);
|
||||
|
||||
$expiresAt = null;
|
||||
if ((int) ($token['expires_in'] ?? 0) > 0) {
|
||||
$expiresAt = (new DateTimeImmutable('now'))
|
||||
->add(new DateInterval('PT' . (int) $token['expires_in'] . 'S'))
|
||||
->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$this->repository->saveTokens(
|
||||
(string) ($token['access_token'] ?? ''),
|
||||
(string) ($token['refresh_token'] ?? ''),
|
||||
(string) ($token['token_type'] ?? ''),
|
||||
(string) ($token['scope'] ?? ''),
|
||||
$expiresAt
|
||||
);
|
||||
|
||||
Flash::set('settings_success', $this->translator->get('settings.allegro.flash.oauth_connected'));
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.flash.oauth_failed') . ' ' . $exception->getMessage());
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/integrations/allegro');
|
||||
}
|
||||
|
||||
public function importSingleOrder(Request $request): Response
|
||||
{
|
||||
$csrfError = $this->validateCsrf((string) $request->input('_token', ''));
|
||||
if ($csrfError !== null) {
|
||||
return $csrfError;
|
||||
}
|
||||
|
||||
$checkoutFormId = trim((string) $request->input('checkout_form_id', ''));
|
||||
if ($checkoutFormId === '') {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.flash.checkout_form_id_required'));
|
||||
return Response::redirect('/settings/integrations/allegro');
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->orderImportService->importSingleOrder($checkoutFormId);
|
||||
$imageDiagnostics = is_array($result['image_diagnostics'] ?? null) ? $result['image_diagnostics'] : [];
|
||||
Flash::set(
|
||||
'settings_success',
|
||||
$this->translator->get('settings.allegro.flash.import_single_ok', [
|
||||
'source_order_id' => (string) ($result['source_order_id'] ?? $checkoutFormId),
|
||||
'local_id' => (string) ((int) ($result['order_id'] ?? 0)),
|
||||
'action' => !empty($result['created'])
|
||||
? $this->translator->get('settings.allegro.import_action.created')
|
||||
: $this->translator->get('settings.allegro.import_action.updated'),
|
||||
]) . ' '
|
||||
. $this->translator->get('settings.allegro.flash.import_single_media_summary', [
|
||||
'with_image' => (string) ((int) ($imageDiagnostics['with_image'] ?? 0)),
|
||||
'total_items' => (string) ((int) ($imageDiagnostics['total_items'] ?? 0)),
|
||||
'without_image' => (string) ((int) ($imageDiagnostics['without_image'] ?? 0)),
|
||||
])
|
||||
);
|
||||
|
||||
$warningDetails = $this->buildImportImageWarningMessage($imageDiagnostics);
|
||||
if ($warningDetails !== '') {
|
||||
Flash::set('settings_warning', $warningDetails);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set(
|
||||
'settings_error',
|
||||
$this->translator->get('settings.allegro.flash.import_single_failed') . ' ' . $exception->getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/integrations/allegro');
|
||||
}
|
||||
|
||||
private function defaultRedirectUri(): string
|
||||
{
|
||||
$base = trim($this->appUrl);
|
||||
if ($base === '') {
|
||||
$base = 'http://localhost:8000';
|
||||
}
|
||||
|
||||
return rtrim($base, '/') . '/settings/integrations/allegro/oauth/callback';
|
||||
}
|
||||
|
||||
private function validateCsrf(string $token): ?Response
|
||||
{
|
||||
if (Csrf::validate($token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
|
||||
return Response::redirect('/settings/integrations/allegro');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function requireOAuthCredentials(): array
|
||||
{
|
||||
$credentials = $this->repository->getOAuthCredentials();
|
||||
if ($credentials === null) {
|
||||
throw new RuntimeException($this->translator->get('settings.allegro.flash.credentials_missing'));
|
||||
}
|
||||
|
||||
return $credentials;
|
||||
}
|
||||
|
||||
private function isValidHttpUrl(string $url): bool
|
||||
{
|
||||
$trimmed = trim($url);
|
||||
if ($trimmed === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filter_var($trimmed, FILTER_VALIDATE_URL) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$scheme = strtolower((string) parse_url($trimmed, PHP_URL_SCHEME));
|
||||
return $scheme === 'http' || $scheme === 'https';
|
||||
}
|
||||
|
||||
private function isValidDate(string $value): bool
|
||||
{
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$date = DateTimeImmutable::createFromFormat('Y-m-d', $value);
|
||||
return $date instanceof DateTimeImmutable && $date->format('Y-m-d') === $value;
|
||||
}
|
||||
|
||||
private function orderStatusCodeExists(string $code): bool
|
||||
{
|
||||
$needle = strtolower(trim($code));
|
||||
if ($needle === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($this->orderStatuses->listStatuses() as $row) {
|
||||
$statusCode = strtolower(trim((string) ($row['code'] ?? '')));
|
||||
if ($statusCode === $needle) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $imageDiagnostics
|
||||
*/
|
||||
private function buildImportImageWarningMessage(array $imageDiagnostics): string
|
||||
{
|
||||
$withoutImage = (int) ($imageDiagnostics['without_image'] ?? 0);
|
||||
if ($withoutImage <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$reasonCountsRaw = $imageDiagnostics['reason_counts'] ?? [];
|
||||
if (!is_array($reasonCountsRaw) || $reasonCountsRaw === []) {
|
||||
return $this->translator->get('settings.allegro.flash.import_single_media_warning_generic', [
|
||||
'without_image' => (string) $withoutImage,
|
||||
]);
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
foreach ($reasonCountsRaw as $reason => $countRaw) {
|
||||
$count = (int) $countRaw;
|
||||
if ($count <= 0) {
|
||||
continue;
|
||||
}
|
||||
$parts[] = $this->reasonLabel((string) $reason) . ': ' . $count;
|
||||
}
|
||||
|
||||
if ($parts === []) {
|
||||
return $this->translator->get('settings.allegro.flash.import_single_media_warning_generic', [
|
||||
'without_image' => (string) $withoutImage,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->translator->get('settings.allegro.flash.import_single_media_warning', [
|
||||
'without_image' => (string) $withoutImage,
|
||||
'reasons' => implode(', ', $parts),
|
||||
]);
|
||||
}
|
||||
|
||||
private function reasonLabel(string $reasonCode): string
|
||||
{
|
||||
return match ($reasonCode) {
|
||||
'missing_offer_id' => 'brak ID oferty',
|
||||
'missing_in_checkout_form' => 'brak obrazka w checkout form',
|
||||
'missing_in_offer_api' => 'brak obrazka w API oferty',
|
||||
'offer_api_access_denied_403' => 'brak uprawnien API ofert (403)',
|
||||
'offer_api_unauthorized_401' => 'token nieautoryzowany dla API ofert (401)',
|
||||
'offer_api_not_found_404' => 'oferta nie znaleziona (404)',
|
||||
'offer_api_request_failed' => 'blad zapytania do API oferty',
|
||||
default => str_starts_with($reasonCode, 'offer_api_http_')
|
||||
? 'blad API oferty (' . str_replace('offer_api_http_', '', $reasonCode) . ')'
|
||||
: $reasonCode,
|
||||
};
|
||||
}
|
||||
|
||||
private function currentImportIntervalSeconds(): int
|
||||
{
|
||||
$schedule = $this->findImportSchedule();
|
||||
$value = (int) ($schedule['interval_seconds'] ?? self::ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS);
|
||||
return max(60, min(86400, $value));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function findImportSchedule(): array
|
||||
{
|
||||
try {
|
||||
$schedules = $this->cronRepository->listSchedules();
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($schedules as $schedule) {
|
||||
if (!is_array($schedule)) {
|
||||
continue;
|
||||
}
|
||||
if ((string) ($schedule['job_type'] ?? '') !== self::ORDERS_IMPORT_JOB_TYPE) {
|
||||
continue;
|
||||
}
|
||||
return $schedule;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function findStatusSyncSchedule(): array
|
||||
{
|
||||
try {
|
||||
$schedules = $this->cronRepository->listSchedules();
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($schedules as $schedule) {
|
||||
if (!is_array($schedule)) {
|
||||
continue;
|
||||
}
|
||||
if ((string) ($schedule['job_type'] ?? '') !== self::STATUS_SYNC_JOB_TYPE) {
|
||||
continue;
|
||||
}
|
||||
return $schedule;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function currentStatusSyncDirection(): string
|
||||
{
|
||||
$value = trim($this->cronRepository->getStringSetting(
|
||||
'allegro_status_sync_direction',
|
||||
self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO
|
||||
));
|
||||
|
||||
if (!in_array($value, $this->allowedStatusSyncDirections(), true)) {
|
||||
return self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function currentStatusSyncIntervalMinutes(): int
|
||||
{
|
||||
return $this->cronRepository->getIntSetting(
|
||||
'allegro_status_sync_interval_minutes',
|
||||
self::STATUS_SYNC_DEFAULT_INTERVAL_MINUTES,
|
||||
1,
|
||||
1440
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function allowedStatusSyncDirections(): array
|
||||
{
|
||||
return [
|
||||
self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO,
|
||||
self::STATUS_SYNC_DIRECTION_ORDERPRO_TO_ALLEGRO,
|
||||
];
|
||||
}
|
||||
}
|
||||
320
src/Modules/Settings/AllegroIntegrationRepository.php
Normal file
320
src/Modules/Settings/AllegroIntegrationRepository.php
Normal file
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class AllegroIntegrationRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly string $secret
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getSettings(): array
|
||||
{
|
||||
$row = $this->fetchRow();
|
||||
if ($row === null) {
|
||||
return $this->defaultSettings();
|
||||
}
|
||||
|
||||
return [
|
||||
'environment' => $this->normalizeEnvironment((string) ($row['environment'] ?? 'sandbox')),
|
||||
'client_id' => trim((string) ($row['client_id'] ?? '')),
|
||||
'has_client_secret' => trim((string) ($row['client_secret_encrypted'] ?? '')) !== '',
|
||||
'redirect_uri' => trim((string) ($row['redirect_uri'] ?? '')),
|
||||
'orders_fetch_enabled' => (int) ($row['orders_fetch_enabled'] ?? 0) === 1,
|
||||
'orders_fetch_start_date' => $this->normalizeDateOrNull((string) ($row['orders_fetch_start_date'] ?? '')),
|
||||
'is_connected' => trim((string) ($row['refresh_token_encrypted'] ?? '')) !== '',
|
||||
'token_expires_at' => trim((string) ($row['token_expires_at'] ?? '')),
|
||||
'connected_at' => trim((string) ($row['connected_at'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function saveSettings(array $payload): void
|
||||
{
|
||||
$this->ensureRow();
|
||||
$current = $this->fetchRow();
|
||||
if ($current === null) {
|
||||
throw new RuntimeException('Brak rekordu konfiguracji Allegro.');
|
||||
}
|
||||
|
||||
$clientSecret = trim((string) ($payload['client_secret'] ?? ''));
|
||||
$clientSecretEncrypted = trim((string) ($current['client_secret_encrypted'] ?? ''));
|
||||
if ($clientSecret !== '') {
|
||||
$clientSecretEncrypted = (string) $this->encrypt($clientSecret);
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'UPDATE allegro_integration_settings
|
||||
SET environment = :environment,
|
||||
client_id = :client_id,
|
||||
client_secret_encrypted = :client_secret_encrypted,
|
||||
redirect_uri = :redirect_uri,
|
||||
orders_fetch_enabled = :orders_fetch_enabled,
|
||||
orders_fetch_start_date = :orders_fetch_start_date,
|
||||
updated_at = NOW()
|
||||
WHERE id = 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'environment' => $this->normalizeEnvironment((string) ($payload['environment'] ?? 'sandbox')),
|
||||
'client_id' => $this->nullableString((string) ($payload['client_id'] ?? '')),
|
||||
'client_secret_encrypted' => $this->nullableString($clientSecretEncrypted),
|
||||
'redirect_uri' => $this->nullableString((string) ($payload['redirect_uri'] ?? '')),
|
||||
'orders_fetch_enabled' => ((bool) ($payload['orders_fetch_enabled'] ?? false)) ? 1 : 0,
|
||||
'orders_fetch_start_date' => $this->nullableString((string) ($payload['orders_fetch_start_date'] ?? '')),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>|null
|
||||
*/
|
||||
public function getOAuthCredentials(): ?array
|
||||
{
|
||||
$row = $this->fetchRow();
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clientId = trim((string) ($row['client_id'] ?? ''));
|
||||
$clientSecret = $this->decrypt((string) ($row['client_secret_encrypted'] ?? ''));
|
||||
$redirectUri = trim((string) ($row['redirect_uri'] ?? ''));
|
||||
if ($clientId === '' || $clientSecret === '' || $redirectUri === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'environment' => $this->normalizeEnvironment((string) ($row['environment'] ?? 'sandbox')),
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
'redirect_uri' => $redirectUri,
|
||||
];
|
||||
}
|
||||
|
||||
public function saveTokens(
|
||||
string $accessToken,
|
||||
string $refreshToken,
|
||||
string $tokenType,
|
||||
string $scope,
|
||||
?string $tokenExpiresAt
|
||||
): void {
|
||||
$this->ensureRow();
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'UPDATE allegro_integration_settings
|
||||
SET access_token_encrypted = :access_token_encrypted,
|
||||
refresh_token_encrypted = :refresh_token_encrypted,
|
||||
token_type = :token_type,
|
||||
token_scope = :token_scope,
|
||||
token_expires_at = :token_expires_at,
|
||||
connected_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'access_token_encrypted' => $this->encrypt($accessToken),
|
||||
'refresh_token_encrypted' => $this->encrypt($refreshToken),
|
||||
'token_type' => $this->nullableString($tokenType),
|
||||
'token_scope' => $this->nullableString($scope),
|
||||
'token_expires_at' => $this->nullableString((string) $tokenExpiresAt),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>|null
|
||||
*/
|
||||
public function getRefreshTokenCredentials(): ?array
|
||||
{
|
||||
$row = $this->fetchRow();
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clientId = trim((string) ($row['client_id'] ?? ''));
|
||||
$clientSecret = $this->decrypt((string) ($row['client_secret_encrypted'] ?? ''));
|
||||
$refreshToken = $this->decrypt((string) ($row['refresh_token_encrypted'] ?? ''));
|
||||
if ($clientId === '' || $clientSecret === '' || $refreshToken === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'environment' => $this->normalizeEnvironment((string) ($row['environment'] ?? 'sandbox')),
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
'refresh_token' => $refreshToken,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>|null
|
||||
*/
|
||||
public function getTokenCredentials(): ?array
|
||||
{
|
||||
$row = $this->fetchRow();
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clientId = trim((string) ($row['client_id'] ?? ''));
|
||||
$clientSecret = $this->decrypt((string) ($row['client_secret_encrypted'] ?? ''));
|
||||
$refreshToken = $this->decrypt((string) ($row['refresh_token_encrypted'] ?? ''));
|
||||
$accessToken = $this->decrypt((string) ($row['access_token_encrypted'] ?? ''));
|
||||
if ($clientId === '' || $clientSecret === '' || $refreshToken === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'environment' => $this->normalizeEnvironment((string) ($row['environment'] ?? 'sandbox')),
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
'refresh_token' => $refreshToken,
|
||||
'access_token' => $accessToken,
|
||||
'token_expires_at' => trim((string) ($row['token_expires_at'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
private function ensureRow(): void
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO allegro_integration_settings (
|
||||
id, environment, orders_fetch_enabled, created_at, updated_at
|
||||
) VALUES (
|
||||
1, :environment, 0, NOW(), NOW()
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
updated_at = VALUES(updated_at)'
|
||||
);
|
||||
$statement->execute([
|
||||
'environment' => 'sandbox',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function fetchRow(): ?array
|
||||
{
|
||||
try {
|
||||
$statement = $this->pdo->prepare('SELECT * FROM allegro_integration_settings WHERE id = 1 LIMIT 1');
|
||||
$statement->execute();
|
||||
$row = $statement->fetch(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function defaultSettings(): array
|
||||
{
|
||||
return [
|
||||
'environment' => 'sandbox',
|
||||
'client_id' => '',
|
||||
'has_client_secret' => false,
|
||||
'redirect_uri' => '',
|
||||
'orders_fetch_enabled' => false,
|
||||
'orders_fetch_start_date' => null,
|
||||
'is_connected' => false,
|
||||
'token_expires_at' => '',
|
||||
'connected_at' => '',
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeEnvironment(string $environment): string
|
||||
{
|
||||
$value = trim(strtolower($environment));
|
||||
if ($value === 'production') {
|
||||
return 'production';
|
||||
}
|
||||
|
||||
return 'sandbox';
|
||||
}
|
||||
|
||||
private function normalizeDateOrNull(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;
|
||||
}
|
||||
|
||||
private function nullableString(string $value): ?string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
return $trimmed === '' ? null : $trimmed;
|
||||
}
|
||||
|
||||
private function encrypt(string $plainText): ?string
|
||||
{
|
||||
$value = trim($plainText);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
if ($this->secret === '') {
|
||||
throw new RuntimeException('Brak INTEGRATIONS_SECRET do szyfrowania danych integracji.');
|
||||
}
|
||||
|
||||
$encryptionKey = hash('sha256', 'enc|' . $this->secret, true);
|
||||
$hmacKey = hash('sha256', 'auth|' . $this->secret, true);
|
||||
$iv = random_bytes(16);
|
||||
$cipherRaw = openssl_encrypt($value, 'AES-256-CBC', $encryptionKey, OPENSSL_RAW_DATA, $iv);
|
||||
if ($cipherRaw === false) {
|
||||
throw new RuntimeException('Nie udalo sie zaszyfrowac danych integracji.');
|
||||
}
|
||||
|
||||
$mac = hash_hmac('sha256', $iv . $cipherRaw, $hmacKey, true);
|
||||
return 'v1:' . base64_encode($iv . $mac . $cipherRaw);
|
||||
}
|
||||
|
||||
private function decrypt(string $encryptedValue): string
|
||||
{
|
||||
$payload = trim($encryptedValue);
|
||||
if ($payload === '') {
|
||||
return '';
|
||||
}
|
||||
if ($this->secret === '') {
|
||||
throw new RuntimeException('Brak INTEGRATIONS_SECRET do odszyfrowania danych integracji.');
|
||||
}
|
||||
if (!str_starts_with($payload, 'v1:')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$raw = base64_decode(substr($payload, 3), true);
|
||||
if ($raw === false || strlen($raw) <= 48) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$iv = substr($raw, 0, 16);
|
||||
$mac = substr($raw, 16, 32);
|
||||
$cipherRaw = substr($raw, 48);
|
||||
|
||||
$hmacKey = hash('sha256', 'auth|' . $this->secret, true);
|
||||
$expectedMac = hash_hmac('sha256', $iv . $cipherRaw, $hmacKey, true);
|
||||
if (!hash_equals($expectedMac, $mac)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$encryptionKey = hash('sha256', 'enc|' . $this->secret, true);
|
||||
$plain = openssl_decrypt($cipherRaw, 'AES-256-CBC', $encryptionKey, OPENSSL_RAW_DATA, $iv);
|
||||
|
||||
return is_string($plain) ? $plain : '';
|
||||
}
|
||||
}
|
||||
177
src/Modules/Settings/AllegroOAuthClient.php
Normal file
177
src/Modules/Settings/AllegroOAuthClient.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class AllegroOAuthClient
|
||||
{
|
||||
public const ORDERS_READ_SCOPE = 'allegro:api:orders:read';
|
||||
public const SALE_OFFERS_READ_SCOPE = 'allegro:api:sale:offers:read';
|
||||
|
||||
/**
|
||||
* @param array<int, string> $scopes
|
||||
*/
|
||||
public function buildAuthorizeUrl(
|
||||
string $environment,
|
||||
string $clientId,
|
||||
string $redirectUri,
|
||||
string $state,
|
||||
array $scopes
|
||||
): string {
|
||||
$scopeValue = trim(implode(' ', array_values(array_filter(
|
||||
$scopes,
|
||||
static fn (mixed $scope): bool => is_string($scope) && trim($scope) !== ''
|
||||
))));
|
||||
|
||||
$query = http_build_query([
|
||||
'response_type' => 'code',
|
||||
'client_id' => $clientId,
|
||||
'redirect_uri' => $redirectUri,
|
||||
'scope' => $scopeValue,
|
||||
'state' => $state,
|
||||
]);
|
||||
|
||||
return $this->authorizeBaseUrl($environment) . '?' . $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{access_token:string, refresh_token:string, token_type:string, scope:string, expires_in:int}
|
||||
*/
|
||||
public function exchangeAuthorizationCode(
|
||||
string $environment,
|
||||
string $clientId,
|
||||
string $clientSecret,
|
||||
string $redirectUri,
|
||||
string $authorizationCode
|
||||
): array {
|
||||
$payload = $this->requestToken(
|
||||
$this->tokenUrl($environment),
|
||||
$clientId,
|
||||
$clientSecret,
|
||||
[
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $authorizationCode,
|
||||
'redirect_uri' => $redirectUri,
|
||||
]
|
||||
);
|
||||
|
||||
$accessToken = trim((string) ($payload['access_token'] ?? ''));
|
||||
$refreshToken = trim((string) ($payload['refresh_token'] ?? ''));
|
||||
if ($accessToken === '' || $refreshToken === '') {
|
||||
throw new RuntimeException('Allegro nie zwrocilo kompletu tokenow OAuth.');
|
||||
}
|
||||
|
||||
return [
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $refreshToken,
|
||||
'token_type' => trim((string) ($payload['token_type'] ?? 'Bearer')),
|
||||
'scope' => trim((string) ($payload['scope'] ?? '')),
|
||||
'expires_in' => max(0, (int) ($payload['expires_in'] ?? 0)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{access_token:string, refresh_token:string, token_type:string, scope:string, expires_in:int}
|
||||
*/
|
||||
public function refreshAccessToken(
|
||||
string $environment,
|
||||
string $clientId,
|
||||
string $clientSecret,
|
||||
string $refreshToken
|
||||
): array {
|
||||
$payload = $this->requestToken(
|
||||
$this->tokenUrl($environment),
|
||||
$clientId,
|
||||
$clientSecret,
|
||||
[
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $refreshToken,
|
||||
]
|
||||
);
|
||||
|
||||
$accessToken = trim((string) ($payload['access_token'] ?? ''));
|
||||
if ($accessToken === '') {
|
||||
throw new RuntimeException('Allegro nie zwrocilo access_token po odswiezeniu.');
|
||||
}
|
||||
|
||||
return [
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => trim((string) ($payload['refresh_token'] ?? '')),
|
||||
'token_type' => trim((string) ($payload['token_type'] ?? 'Bearer')),
|
||||
'scope' => trim((string) ($payload['scope'] ?? '')),
|
||||
'expires_in' => max(0, (int) ($payload['expires_in'] ?? 0)),
|
||||
];
|
||||
}
|
||||
|
||||
private function authorizeBaseUrl(string $environment): string
|
||||
{
|
||||
return $this->normalizeEnvironment($environment) === 'production'
|
||||
? 'https://allegro.pl/auth/oauth/authorize'
|
||||
: 'https://allegro.pl.allegrosandbox.pl/auth/oauth/authorize';
|
||||
}
|
||||
|
||||
private function tokenUrl(string $environment): string
|
||||
{
|
||||
return $this->normalizeEnvironment($environment) === 'production'
|
||||
? 'https://allegro.pl/auth/oauth/token'
|
||||
: 'https://allegro.pl.allegrosandbox.pl/auth/oauth/token';
|
||||
}
|
||||
|
||||
private function normalizeEnvironment(string $environment): string
|
||||
{
|
||||
return trim(strtolower($environment)) === 'production' ? 'production' : 'sandbox';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $formData
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function requestToken(
|
||||
string $url,
|
||||
string $clientId,
|
||||
string $clientSecret,
|
||||
array $formData
|
||||
): array {
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
throw new RuntimeException('Nie udalo sie zainicjowac polaczenia OAuth z Allegro.');
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'Authorization: Basic ' . base64_encode($clientId . ':' . $clientSecret),
|
||||
],
|
||||
CURLOPT_POSTFIELDS => http_build_query($formData),
|
||||
]);
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
$ch = null;
|
||||
|
||||
if ($responseBody === false) {
|
||||
throw new RuntimeException('Blad polaczenia OAuth z Allegro: ' . $curlError);
|
||||
}
|
||||
|
||||
$json = json_decode((string) $responseBody, true);
|
||||
if (!is_array($json)) {
|
||||
throw new RuntimeException('Nieprawidlowy JSON odpowiedzi OAuth Allegro.');
|
||||
}
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
$error = trim((string) ($json['error'] ?? 'oauth_error'));
|
||||
$description = trim((string) ($json['error_description'] ?? 'Brak szczegolow bledu OAuth.'));
|
||||
throw new RuntimeException('OAuth Allegro [' . $error . ']: ' . $description);
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
}
|
||||
801
src/Modules/Settings/AllegroOrderImportService.php
Normal file
801
src/Modules/Settings/AllegroOrderImportService.php
Normal file
@@ -0,0 +1,801 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use App\Modules\Orders\OrderImportRepository;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class AllegroOrderImportService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AllegroIntegrationRepository $integrationRepository,
|
||||
private readonly AllegroOAuthClient $oauthClient,
|
||||
private readonly AllegroApiClient $apiClient,
|
||||
private readonly OrderImportRepository $orders,
|
||||
private readonly AllegroStatusMappingRepository $statusMappings
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function importSingleOrder(string $checkoutFormId): array
|
||||
{
|
||||
$orderId = trim($checkoutFormId);
|
||||
if ($orderId === '') {
|
||||
throw new RuntimeException('Podaj ID zamowienia Allegro.');
|
||||
}
|
||||
|
||||
$oauth = $this->requireOAuthData();
|
||||
[$accessToken, $oauth] = $this->resolveAccessToken($oauth);
|
||||
|
||||
try {
|
||||
$payload = $this->apiClient->getCheckoutForm(
|
||||
(string) ($oauth['environment'] ?? 'sandbox'),
|
||||
$accessToken,
|
||||
$orderId
|
||||
);
|
||||
} catch (RuntimeException $exception) {
|
||||
if (trim($exception->getMessage()) !== 'ALLEGRO_HTTP_401') {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
[$accessToken, $oauth] = $this->forceRefreshToken($oauth);
|
||||
$payload = $this->apiClient->getCheckoutForm(
|
||||
(string) ($oauth['environment'] ?? 'sandbox'),
|
||||
$accessToken,
|
||||
$orderId
|
||||
);
|
||||
}
|
||||
|
||||
$mapped = $this->mapCheckoutFormPayload(
|
||||
$payload,
|
||||
(string) ($oauth['environment'] ?? 'sandbox'),
|
||||
$accessToken
|
||||
);
|
||||
$saveResult = $this->orders->upsertOrderAggregate(
|
||||
$mapped['order'],
|
||||
$mapped['addresses'],
|
||||
$mapped['items'],
|
||||
$mapped['payments'],
|
||||
$mapped['shipments'],
|
||||
$mapped['notes'],
|
||||
$mapped['status_history']
|
||||
);
|
||||
|
||||
return [
|
||||
'order_id' => (int) ($saveResult['order_id'] ?? 0),
|
||||
'created' => !empty($saveResult['created']),
|
||||
'source_order_id' => (string) ($mapped['order']['source_order_id'] ?? ''),
|
||||
'image_diagnostics' => (array) ($mapped['image_diagnostics'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{
|
||||
* order:array<string, mixed>,
|
||||
* addresses:array<int, array<string, mixed>>,
|
||||
* items:array<int, array<string, mixed>>,
|
||||
* image_diagnostics:array<string, mixed>,
|
||||
* payments:array<int, array<string, mixed>>,
|
||||
* shipments:array<int, array<string, mixed>>,
|
||||
* notes:array<int, array<string, mixed>>,
|
||||
* status_history:array<int, array<string, mixed>>
|
||||
* }
|
||||
*/
|
||||
private function mapCheckoutFormPayload(array $payload, string $environment, string $accessToken): array
|
||||
{
|
||||
$checkoutFormId = trim((string) ($payload['id'] ?? ''));
|
||||
if ($checkoutFormId === '') {
|
||||
throw new RuntimeException('Odpowiedz Allegro nie zawiera ID zamowienia.');
|
||||
}
|
||||
|
||||
$status = trim((string) ($payload['status'] ?? ''));
|
||||
$fulfillmentStatus = trim((string) ($payload['fulfillment']['status'] ?? ''));
|
||||
$rawAllegroStatus = strtolower($fulfillmentStatus !== '' ? $fulfillmentStatus : $status);
|
||||
$mappedOrderproStatus = $this->statusMappings->findMappedOrderproStatusCode($rawAllegroStatus);
|
||||
$externalStatus = $mappedOrderproStatus !== null ? $mappedOrderproStatus : $rawAllegroStatus;
|
||||
$paymentStatusRaw = strtolower(trim((string) ($payload['payment']['status'] ?? '')));
|
||||
|
||||
$totalWithTax = $this->amountToFloat($payload['summary']['totalToPay'] ?? null);
|
||||
$totalPaid = $this->amountToFloat($payload['summary']['paidAmount'] ?? null);
|
||||
if ($totalPaid === null) {
|
||||
$totalPaid = $this->amountToFloat($payload['payment']['paidAmount'] ?? null);
|
||||
}
|
||||
if ($totalPaid === null) {
|
||||
$totalPaid = $this->amountToFloat($payload['payment']['amount'] ?? null);
|
||||
}
|
||||
|
||||
$currency = trim((string) ($payload['summary']['totalToPay']['currency'] ?? ''));
|
||||
if ($currency === '') {
|
||||
$currency = trim((string) ($payload['payment']['amount']['currency'] ?? 'PLN'));
|
||||
}
|
||||
if ($currency === '') {
|
||||
$currency = 'PLN';
|
||||
}
|
||||
|
||||
$buyer = is_array($payload['buyer'] ?? null) ? $payload['buyer'] : [];
|
||||
$delivery = is_array($payload['delivery'] ?? null) ? $payload['delivery'] : [];
|
||||
$invoice = is_array($payload['invoice'] ?? null) ? $payload['invoice'] : [];
|
||||
$payment = is_array($payload['payment'] ?? null) ? $payload['payment'] : [];
|
||||
$lineItems = is_array($payload['lineItems'] ?? null) ? $payload['lineItems'] : [];
|
||||
$deliveryMethod = is_array($delivery['method'] ?? null) ? $delivery['method'] : [];
|
||||
$deliveryMethodId = trim((string) ($deliveryMethod['id'] ?? ''));
|
||||
$deliveryMethodName = trim((string) ($deliveryMethod['name'] ?? ''));
|
||||
$deliveryForm = $deliveryMethodName !== '' ? $deliveryMethodName : $deliveryMethodId;
|
||||
$deliveryTime = is_array($delivery['time'] ?? null) ? $delivery['time'] : [];
|
||||
$dispatchTime = is_array($deliveryTime['dispatch'] ?? null) ? $deliveryTime['dispatch'] : [];
|
||||
$sendDateMin = $this->normalizeDateTime((string) ($dispatchTime['from'] ?? ''));
|
||||
$sendDateMax = $this->normalizeDateTime((string) ($dispatchTime['to'] ?? ''));
|
||||
if ($sendDateMin === null) {
|
||||
$sendDateMin = $this->normalizeDateTime((string) ($deliveryTime['from'] ?? ''));
|
||||
}
|
||||
if ($sendDateMax === null) {
|
||||
$sendDateMax = $this->normalizeDateTime((string) ($deliveryTime['to'] ?? ''));
|
||||
}
|
||||
|
||||
$boughtAt = $this->normalizeDateTime((string) ($payload['boughtAt'] ?? ''));
|
||||
$updatedAt = $this->normalizeDateTime((string) ($payload['updatedAt'] ?? ''));
|
||||
$fetchedAt = date('Y-m-d H:i:s');
|
||||
|
||||
$order = [
|
||||
'integration_id' => null,
|
||||
'source' => 'allegro',
|
||||
'source_order_id' => $checkoutFormId,
|
||||
'external_order_id' => $checkoutFormId,
|
||||
'external_platform_id' => trim((string) ($payload['marketplace']['id'] ?? 'allegro-pl')),
|
||||
'external_platform_account_id' => null,
|
||||
'external_status_id' => $externalStatus,
|
||||
'external_payment_type_id' => trim((string) ($payment['type'] ?? '')),
|
||||
'payment_status' => $this->mapPaymentStatus($paymentStatusRaw),
|
||||
'external_carrier_id' => $deliveryForm !== '' ? $deliveryForm : null,
|
||||
'external_carrier_account_id' => $deliveryMethodId !== '' ? $deliveryMethodId : null,
|
||||
'customer_login' => trim((string) ($buyer['login'] ?? '')),
|
||||
'is_invoice' => !empty($invoice['required']),
|
||||
'is_encrypted' => false,
|
||||
'is_canceled_by_buyer' => in_array($externalStatus, ['cancelled', 'canceled'], true),
|
||||
'currency' => strtoupper($currency),
|
||||
'total_without_tax' => null,
|
||||
'total_with_tax' => $totalWithTax,
|
||||
'total_paid' => $totalPaid,
|
||||
'send_date_min' => $sendDateMin,
|
||||
'send_date_max' => $sendDateMax,
|
||||
'ordered_at' => $boughtAt,
|
||||
'source_created_at' => $boughtAt,
|
||||
'source_updated_at' => $updatedAt,
|
||||
'preferences_json' => [
|
||||
'status' => $status,
|
||||
'fulfillment_status' => $fulfillmentStatus,
|
||||
'allegro_status_raw' => $rawAllegroStatus,
|
||||
'payment_status' => $paymentStatusRaw,
|
||||
'delivery_method_name' => $deliveryMethodName,
|
||||
'delivery_method_id' => $deliveryMethodId,
|
||||
'delivery_cost' => $delivery['cost'] ?? null,
|
||||
'delivery_time' => $deliveryTime,
|
||||
],
|
||||
'payload_json' => $payload,
|
||||
'fetched_at' => $fetchedAt,
|
||||
];
|
||||
|
||||
$addresses = $this->buildAddresses($buyer, $delivery, $invoice);
|
||||
$itemsResult = $this->buildItems($lineItems, $environment, $accessToken);
|
||||
$items = (array) ($itemsResult['items'] ?? []);
|
||||
$payments = $this->buildPayments($payment, $currency);
|
||||
$shipments = $this->buildShipments($payload, $delivery);
|
||||
$notes = $this->buildNotes($payload);
|
||||
$statusHistory = [[
|
||||
'from_status_id' => null,
|
||||
'to_status_id' => $externalStatus !== '' ? $externalStatus : 'unknown',
|
||||
'changed_at' => $updatedAt !== null ? $updatedAt : $fetchedAt,
|
||||
'change_source' => 'import',
|
||||
'comment' => 'Import z Allegro checkout form',
|
||||
'payload_json' => [
|
||||
'status' => $status,
|
||||
'fulfillment_status' => $fulfillmentStatus,
|
||||
'allegro_status_raw' => $rawAllegroStatus,
|
||||
],
|
||||
]];
|
||||
|
||||
return [
|
||||
'order' => $order,
|
||||
'addresses' => $addresses,
|
||||
'items' => $items,
|
||||
'image_diagnostics' => (array) ($itemsResult['image_diagnostics'] ?? []),
|
||||
'payments' => $payments,
|
||||
'shipments' => $shipments,
|
||||
'notes' => $notes,
|
||||
'status_history' => $statusHistory,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $buyer
|
||||
* @param array<string, mixed> $delivery
|
||||
* @param array<string, mixed> $invoice
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function buildAddresses(array $buyer, array $delivery, array $invoice): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
$customerName = trim((string) (($buyer['firstName'] ?? '') . ' ' . ($buyer['lastName'] ?? '')));
|
||||
if ($customerName === '') {
|
||||
$customerName = trim((string) ($buyer['login'] ?? ''));
|
||||
}
|
||||
if ($customerName === '') {
|
||||
$customerName = 'Kupujacy Allegro';
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'address_type' => 'customer',
|
||||
'name' => $customerName,
|
||||
'phone' => $this->nullableString((string) ($buyer['phoneNumber'] ?? '')),
|
||||
'email' => $this->nullableString((string) ($buyer['email'] ?? '')),
|
||||
'street_name' => null,
|
||||
'street_number' => null,
|
||||
'city' => null,
|
||||
'zip_code' => null,
|
||||
'country' => null,
|
||||
'department' => null,
|
||||
'parcel_external_id' => null,
|
||||
'parcel_name' => null,
|
||||
'address_class' => null,
|
||||
'company_tax_number' => null,
|
||||
'company_name' => null,
|
||||
'payload_json' => $buyer,
|
||||
];
|
||||
|
||||
$deliveryAddress = is_array($delivery['address'] ?? null) ? $delivery['address'] : [];
|
||||
$pickupPoint = is_array($delivery['pickupPoint'] ?? null) ? $delivery['pickupPoint'] : [];
|
||||
$pickupAddress = is_array($pickupPoint['address'] ?? null) ? $pickupPoint['address'] : [];
|
||||
if ($deliveryAddress !== [] || $pickupAddress !== []) {
|
||||
$isPickupPointDelivery = $pickupAddress !== [];
|
||||
$name = $isPickupPointDelivery
|
||||
? $this->nullableString((string) ($pickupPoint['name'] ?? ''))
|
||||
: $this->fallbackName($deliveryAddress, 'Dostawa');
|
||||
if ($name === null) {
|
||||
$name = 'Dostawa';
|
||||
}
|
||||
|
||||
$street = $isPickupPointDelivery
|
||||
? $this->nullableString((string) ($pickupAddress['street'] ?? ''))
|
||||
: $this->nullableString((string) ($deliveryAddress['street'] ?? ''));
|
||||
$city = $isPickupPointDelivery
|
||||
? $this->nullableString((string) ($pickupAddress['city'] ?? ''))
|
||||
: $this->nullableString((string) ($deliveryAddress['city'] ?? ''));
|
||||
$zipCode = $isPickupPointDelivery
|
||||
? $this->nullableString((string) ($pickupAddress['zipCode'] ?? ''))
|
||||
: $this->nullableString((string) ($deliveryAddress['zipCode'] ?? ''));
|
||||
$country = $isPickupPointDelivery
|
||||
? $this->nullableString((string) ($pickupAddress['countryCode'] ?? ''))
|
||||
: $this->nullableString((string) ($deliveryAddress['countryCode'] ?? ''));
|
||||
|
||||
$result[] = [
|
||||
'address_type' => 'delivery',
|
||||
'name' => $name,
|
||||
'phone' => $this->nullableString((string) ($deliveryAddress['phoneNumber'] ?? '')),
|
||||
'email' => $this->nullableString((string) ($deliveryAddress['email'] ?? $buyer['email'] ?? '')),
|
||||
'street_name' => $street,
|
||||
'street_number' => null,
|
||||
'city' => $city,
|
||||
'zip_code' => $zipCode,
|
||||
'country' => $country,
|
||||
'department' => null,
|
||||
'parcel_external_id' => $this->nullableString((string) ($pickupPoint['id'] ?? '')),
|
||||
'parcel_name' => $this->nullableString((string) ($pickupPoint['name'] ?? '')),
|
||||
'address_class' => null,
|
||||
'company_tax_number' => null,
|
||||
'company_name' => $this->nullableString((string) ($deliveryAddress['companyName'] ?? '')),
|
||||
'payload_json' => [
|
||||
'address' => $deliveryAddress,
|
||||
'pickup_point' => $pickupPoint,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$invoiceAddress = is_array($invoice['address'] ?? null) ? $invoice['address'] : [];
|
||||
if ($invoiceAddress !== []) {
|
||||
$result[] = [
|
||||
'address_type' => 'invoice',
|
||||
'name' => $this->fallbackName($invoiceAddress, 'Faktura'),
|
||||
'phone' => $this->nullableString((string) ($invoiceAddress['phoneNumber'] ?? '')),
|
||||
'email' => $this->nullableString((string) ($invoiceAddress['email'] ?? '')),
|
||||
'street_name' => $this->nullableString((string) ($invoiceAddress['street'] ?? '')),
|
||||
'street_number' => null,
|
||||
'city' => $this->nullableString((string) ($invoiceAddress['city'] ?? '')),
|
||||
'zip_code' => $this->nullableString((string) ($invoiceAddress['zipCode'] ?? '')),
|
||||
'country' => $this->nullableString((string) ($invoiceAddress['countryCode'] ?? '')),
|
||||
'department' => null,
|
||||
'parcel_external_id' => null,
|
||||
'parcel_name' => null,
|
||||
'address_class' => null,
|
||||
'company_tax_number' => $this->nullableString((string) ($invoiceAddress['taxId'] ?? '')),
|
||||
'company_name' => $this->nullableString((string) ($invoiceAddress['companyName'] ?? '')),
|
||||
'payload_json' => $invoiceAddress,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $lineItems
|
||||
* @return array{
|
||||
* items:array<int, array<string, mixed>>,
|
||||
* image_diagnostics:array<string, mixed>
|
||||
* }
|
||||
*/
|
||||
private function buildItems(array $lineItems, string $environment, string $accessToken): array
|
||||
{
|
||||
$result = [];
|
||||
$offerImageCache = [];
|
||||
$diagnostics = [
|
||||
'total_items' => 0,
|
||||
'with_image' => 0,
|
||||
'without_image' => 0,
|
||||
'source_counts' => [
|
||||
'checkout_form' => 0,
|
||||
'offer_api' => 0,
|
||||
],
|
||||
'reason_counts' => [],
|
||||
'sample_issues' => [],
|
||||
];
|
||||
$sortOrder = 0;
|
||||
foreach ($lineItems as $itemRaw) {
|
||||
if (!is_array($itemRaw)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diagnostics['total_items'] = (int) $diagnostics['total_items'] + 1;
|
||||
$offer = is_array($itemRaw['offer'] ?? null) ? $itemRaw['offer'] : [];
|
||||
$name = trim((string) ($offer['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$name = 'Pozycja Allegro';
|
||||
}
|
||||
|
||||
$offerId = trim((string) ($offer['id'] ?? ''));
|
||||
$mediaUrl = $this->extractLineItemImageUrl($itemRaw);
|
||||
$imageSource = 'none';
|
||||
$missingReason = null;
|
||||
if ($mediaUrl === null && $offerId !== '') {
|
||||
$offerImageResult = $this->resolveOfferImageUrlFromApi($offerId, $environment, $accessToken, $offerImageCache);
|
||||
$mediaUrl = $offerImageResult['url'];
|
||||
if ($mediaUrl !== null) {
|
||||
$imageSource = 'offer_api';
|
||||
} else {
|
||||
$missingReason = $offerImageResult['reason'];
|
||||
}
|
||||
} elseif ($mediaUrl === null) {
|
||||
$missingReason = 'missing_offer_id';
|
||||
} else {
|
||||
$imageSource = 'checkout_form';
|
||||
}
|
||||
|
||||
if ($mediaUrl !== null) {
|
||||
$diagnostics['with_image'] = (int) $diagnostics['with_image'] + 1;
|
||||
if ($imageSource === 'offer_api') {
|
||||
$diagnostics['source_counts']['offer_api'] = (int) ($diagnostics['source_counts']['offer_api'] ?? 0) + 1;
|
||||
} else {
|
||||
$diagnostics['source_counts']['checkout_form'] = (int) ($diagnostics['source_counts']['checkout_form'] ?? 0) + 1;
|
||||
}
|
||||
} else {
|
||||
$diagnostics['without_image'] = (int) $diagnostics['without_image'] + 1;
|
||||
$reasonCode = $missingReason ?? 'missing_in_checkout_form';
|
||||
$reasonCounts = is_array($diagnostics['reason_counts']) ? $diagnostics['reason_counts'] : [];
|
||||
$reasonCounts[$reasonCode] = (int) ($reasonCounts[$reasonCode] ?? 0) + 1;
|
||||
$diagnostics['reason_counts'] = $reasonCounts;
|
||||
|
||||
$sampleIssues = is_array($diagnostics['sample_issues']) ? $diagnostics['sample_issues'] : [];
|
||||
if (count($sampleIssues) < 5) {
|
||||
$sampleIssues[] = [
|
||||
'offer_id' => $offerId,
|
||||
'name' => $name,
|
||||
'reason' => $reasonCode,
|
||||
];
|
||||
}
|
||||
$diagnostics['sample_issues'] = $sampleIssues;
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'source_item_id' => $this->nullableString((string) ($itemRaw['id'] ?? '')),
|
||||
'external_item_id' => $this->nullableString((string) ($offer['id'] ?? '')),
|
||||
'ean' => null,
|
||||
'sku' => null,
|
||||
'original_name' => $name,
|
||||
'original_code' => $this->nullableString((string) ($offer['id'] ?? '')),
|
||||
'original_price_with_tax' => $this->amountToFloat($itemRaw['originalPrice'] ?? null),
|
||||
'original_price_without_tax' => null,
|
||||
'media_url' => $mediaUrl,
|
||||
'quantity' => (float) ($itemRaw['quantity'] ?? 1),
|
||||
'tax_rate' => null,
|
||||
'item_status' => null,
|
||||
'unit' => 'pcs',
|
||||
'item_type' => 'product',
|
||||
'source_product_id' => $this->nullableString((string) ($offer['id'] ?? '')),
|
||||
'source_product_set_id' => null,
|
||||
'sort_order' => $sortOrder++,
|
||||
'payload_json' => $itemRaw,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $result,
|
||||
'image_diagnostics' => $diagnostics,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{url:?string, reason:?string}> $offerImageCache
|
||||
* @return array{url:?string, reason:?string}
|
||||
*/
|
||||
private function resolveOfferImageUrlFromApi(
|
||||
string $offerId,
|
||||
string $environment,
|
||||
string $accessToken,
|
||||
array &$offerImageCache
|
||||
): array {
|
||||
if (array_key_exists($offerId, $offerImageCache)) {
|
||||
return $offerImageCache[$offerId];
|
||||
}
|
||||
|
||||
try {
|
||||
$offerPayload = $this->apiClient->getProductOffer($environment, $accessToken, $offerId);
|
||||
$url = $this->extractOfferImageUrl($offerPayload);
|
||||
if ($url !== null) {
|
||||
$offerImageCache[$offerId] = ['url' => $url, 'reason' => null];
|
||||
return $offerImageCache[$offerId];
|
||||
}
|
||||
$offerImageCache[$offerId] = ['url' => null, 'reason' => 'missing_in_offer_api'];
|
||||
} catch (Throwable $exception) {
|
||||
$reason = $this->mapOfferApiErrorToReason($exception->getMessage());
|
||||
$offerImageCache[$offerId] = ['url' => null, 'reason' => $reason];
|
||||
}
|
||||
|
||||
return $offerImageCache[$offerId];
|
||||
}
|
||||
|
||||
private function mapOfferApiErrorToReason(string $message): string
|
||||
{
|
||||
$normalized = strtoupper(trim($message));
|
||||
if (str_contains($normalized, 'HTTP 403')) {
|
||||
return 'offer_api_access_denied_403';
|
||||
}
|
||||
if (str_contains($normalized, 'HTTP 401')) {
|
||||
return 'offer_api_unauthorized_401';
|
||||
}
|
||||
if (str_contains($normalized, 'HTTP 404')) {
|
||||
return 'offer_api_not_found_404';
|
||||
}
|
||||
if (preg_match('/HTTP\s+(\d{3})/', $normalized, $matches) === 1) {
|
||||
return 'offer_api_http_' . $matches[1];
|
||||
}
|
||||
|
||||
return 'offer_api_request_failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $offerPayload
|
||||
*/
|
||||
private function extractOfferImageUrl(array $offerPayload): ?string
|
||||
{
|
||||
$candidates = [
|
||||
(string) ($offerPayload['imageUrl'] ?? ''),
|
||||
(string) ($offerPayload['image']['url'] ?? ''),
|
||||
];
|
||||
|
||||
$images = $offerPayload['images'] ?? null;
|
||||
if (is_array($images)) {
|
||||
$firstImage = $images[0] ?? null;
|
||||
if (is_array($firstImage)) {
|
||||
$candidates[] = (string) ($firstImage['url'] ?? '');
|
||||
} elseif (is_string($firstImage)) {
|
||||
$candidates[] = $firstImage;
|
||||
}
|
||||
}
|
||||
|
||||
$productSet = $offerPayload['productSet'] ?? null;
|
||||
if (is_array($productSet)) {
|
||||
$firstSet = $productSet[0] ?? null;
|
||||
if (is_array($firstSet)) {
|
||||
$product = is_array($firstSet['product'] ?? null) ? $firstSet['product'] : [];
|
||||
$productImage = is_array($product['images'] ?? null) ? ($product['images'][0] ?? null) : null;
|
||||
if (is_array($productImage)) {
|
||||
$candidates[] = (string) ($productImage['url'] ?? '');
|
||||
} elseif (is_string($productImage)) {
|
||||
$candidates[] = $productImage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$url = trim($candidate);
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($url, '//')) {
|
||||
return 'https:' . $url;
|
||||
}
|
||||
if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
|
||||
continue;
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $itemRaw
|
||||
*/
|
||||
private function extractLineItemImageUrl(array $itemRaw): ?string
|
||||
{
|
||||
$offer = is_array($itemRaw['offer'] ?? null) ? $itemRaw['offer'] : [];
|
||||
|
||||
$candidates = [
|
||||
(string) ($itemRaw['imageUrl'] ?? ''),
|
||||
(string) ($offer['imageUrl'] ?? ''),
|
||||
(string) ($offer['image']['url'] ?? ''),
|
||||
];
|
||||
|
||||
$images = $offer['images'] ?? null;
|
||||
if (is_array($images)) {
|
||||
$firstImage = $images[0] ?? null;
|
||||
if (is_array($firstImage)) {
|
||||
$candidates[] = (string) ($firstImage['url'] ?? '');
|
||||
} elseif (is_string($firstImage)) {
|
||||
$candidates[] = $firstImage;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$url = trim($candidate);
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($url, '//')) {
|
||||
return 'https:' . $url;
|
||||
}
|
||||
if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
|
||||
continue;
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payment
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function buildPayments(array $payment, string $fallbackCurrency): array
|
||||
{
|
||||
$paymentId = trim((string) ($payment['id'] ?? ''));
|
||||
if ($paymentId === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$amount = $this->amountToFloat($payment['paidAmount'] ?? null);
|
||||
if ($amount === null) {
|
||||
$amount = $this->amountToFloat($payment['amount'] ?? null);
|
||||
}
|
||||
|
||||
return [[
|
||||
'source_payment_id' => $paymentId,
|
||||
'external_payment_id' => $paymentId,
|
||||
'payment_type_id' => trim((string) ($payment['type'] ?? 'allegro')),
|
||||
'payment_date' => $this->normalizeDateTime((string) ($payment['finishedAt'] ?? '')),
|
||||
'amount' => $amount,
|
||||
'currency' => $this->nullableString((string) ($payment['amount']['currency'] ?? $fallbackCurrency)),
|
||||
'comment' => $this->nullableString((string) ($payment['provider'] ?? '')),
|
||||
'payload_json' => $payment,
|
||||
]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<string, mixed> $delivery
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function buildShipments(array $payload, array $delivery): array
|
||||
{
|
||||
$shipments = is_array($payload['fulfillment']['shipments'] ?? null)
|
||||
? $payload['fulfillment']['shipments']
|
||||
: [];
|
||||
|
||||
$result = [];
|
||||
foreach ($shipments as $shipmentRaw) {
|
||||
if (!is_array($shipmentRaw)) {
|
||||
continue;
|
||||
}
|
||||
$trackingNumber = trim((string) ($shipmentRaw['waybill'] ?? $shipmentRaw['trackingNumber'] ?? ''));
|
||||
if ($trackingNumber === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$carrierId = trim((string) ($shipmentRaw['carrierId'] ?? $delivery['method']['id'] ?? 'allegro'));
|
||||
$result[] = [
|
||||
'source_shipment_id' => $this->nullableString((string) ($shipmentRaw['id'] ?? '')),
|
||||
'external_shipment_id' => $this->nullableString((string) ($shipmentRaw['id'] ?? '')),
|
||||
'tracking_number' => $trackingNumber,
|
||||
'carrier_provider_id' => $carrierId !== '' ? $carrierId : 'allegro',
|
||||
'posted_at' => $this->normalizeDateTime((string) ($shipmentRaw['createdAt'] ?? $payload['updatedAt'] ?? '')),
|
||||
'media_uuid' => null,
|
||||
'payload_json' => $shipmentRaw,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function buildNotes(array $payload): array
|
||||
{
|
||||
$message = trim((string) ($payload['messageToSeller'] ?? ''));
|
||||
if ($message === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [[
|
||||
'source_note_id' => null,
|
||||
'note_type' => 'buyer_message',
|
||||
'created_at_external' => $this->normalizeDateTime((string) ($payload['updatedAt'] ?? '')),
|
||||
'comment' => $message,
|
||||
'payload_json' => ['messageToSeller' => $message],
|
||||
]];
|
||||
}
|
||||
|
||||
private function mapPaymentStatus(string $status): ?int
|
||||
{
|
||||
return match ($status) {
|
||||
'paid', 'finished', 'completed' => 2,
|
||||
'partially_paid', 'in_progress' => 1,
|
||||
'cancelled', 'canceled', 'failed', 'unpaid' => 0,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function amountToFloat(mixed $amountNode): ?float
|
||||
{
|
||||
if (!is_array($amountNode)) {
|
||||
return null;
|
||||
}
|
||||
$value = trim((string) ($amountNode['amount'] ?? ''));
|
||||
if ($value === '' || !is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $address
|
||||
*/
|
||||
private function fallbackName(array $address, string $fallback): string
|
||||
{
|
||||
$name = trim((string) (($address['firstName'] ?? '') . ' ' . ($address['lastName'] ?? '')));
|
||||
if ($name !== '') {
|
||||
return $name;
|
||||
}
|
||||
|
||||
$company = trim((string) ($address['companyName'] ?? ''));
|
||||
if ($company !== '') {
|
||||
return $company;
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
private function nullableString(string $value): ?string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
return $trimmed === '' ? null : $trimmed;
|
||||
}
|
||||
}
|
||||
275
src/Modules/Settings/AllegroOrderSyncStateRepository.php
Normal file
275
src/Modules/Settings/AllegroOrderSyncStateRepository.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
final class AllegroOrderSyncStateRepository
|
||||
{
|
||||
private ?array $columns = null;
|
||||
|
||||
public function __construct(private readonly PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* last_synced_updated_at:?string,
|
||||
* last_synced_source_order_id:?string,
|
||||
* last_run_at:?string,
|
||||
* last_success_at:?string,
|
||||
* last_error:?string
|
||||
* }
|
||||
*/
|
||||
public function getState(int $integrationId): array
|
||||
{
|
||||
$default = $this->defaultState();
|
||||
if ($integrationId <= 0) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$columns = $this->resolveColumns();
|
||||
if (!$columns['has_table']) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$updatedAtColumn = $columns['updated_at_column'];
|
||||
$sourceOrderIdColumn = $columns['source_order_id_column'];
|
||||
if ($updatedAtColumn === null || $sourceOrderIdColumn === null) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$selectParts = [
|
||||
$updatedAtColumn . ' AS last_synced_updated_at',
|
||||
$sourceOrderIdColumn . ' AS last_synced_source_order_id',
|
||||
'last_run_at',
|
||||
$columns['has_last_success_at'] ? 'last_success_at' : 'NULL AS last_success_at',
|
||||
'last_error',
|
||||
];
|
||||
|
||||
try {
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT ' . implode(', ', $selectParts) . '
|
||||
FROM integration_order_sync_state
|
||||
WHERE integration_id = :integration_id
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute(['integration_id' => $integrationId]);
|
||||
$row = $statement->fetch(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
if (!is_array($row)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return [
|
||||
'last_synced_updated_at' => $this->nullableString((string) ($row['last_synced_updated_at'] ?? '')),
|
||||
'last_synced_source_order_id' => $this->nullableString((string) ($row['last_synced_source_order_id'] ?? '')),
|
||||
'last_run_at' => $this->nullableString((string) ($row['last_run_at'] ?? '')),
|
||||
'last_success_at' => $this->nullableString((string) ($row['last_success_at'] ?? '')),
|
||||
'last_error' => $this->nullableString((string) ($row['last_error'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
public function markRunStarted(int $integrationId, DateTimeImmutable $now): void
|
||||
{
|
||||
$this->upsertState($integrationId, [
|
||||
'last_run_at' => $now->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markRunFailed(int $integrationId, DateTimeImmutable $now, string $error): void
|
||||
{
|
||||
$this->upsertState($integrationId, [
|
||||
'last_run_at' => $now->format('Y-m-d H:i:s'),
|
||||
'last_error' => mb_substr(trim($error), 0, 500),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markRunSuccess(
|
||||
int $integrationId,
|
||||
DateTimeImmutable $now,
|
||||
?string $lastSyncedUpdatedAt,
|
||||
?string $lastSyncedSourceOrderId
|
||||
): void {
|
||||
$changes = [
|
||||
'last_run_at' => $now->format('Y-m-d H:i:s'),
|
||||
'last_error' => null,
|
||||
];
|
||||
|
||||
if ($lastSyncedUpdatedAt !== null) {
|
||||
$changes['last_synced_updated_at'] = $lastSyncedUpdatedAt;
|
||||
}
|
||||
if ($lastSyncedSourceOrderId !== null) {
|
||||
$changes['last_synced_source_order_id'] = $lastSyncedSourceOrderId;
|
||||
}
|
||||
|
||||
$this->upsertState($integrationId, $changes, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $changes
|
||||
*/
|
||||
private function upsertState(int $integrationId, array $changes, bool $setSuccessAt = false): void
|
||||
{
|
||||
if ($integrationId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$columns = $this->resolveColumns();
|
||||
if (!$columns['has_table']) {
|
||||
return;
|
||||
}
|
||||
|
||||
$updatedAtColumn = $columns['updated_at_column'];
|
||||
$sourceOrderIdColumn = $columns['source_order_id_column'];
|
||||
if ($updatedAtColumn === null || $sourceOrderIdColumn === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$insertColumns = ['integration_id', 'created_at', 'updated_at'];
|
||||
$insertValues = [':integration_id', ':created_at', ':updated_at'];
|
||||
$updateParts = ['updated_at = VALUES(updated_at)'];
|
||||
$params = [
|
||||
'integration_id' => $integrationId,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
$columnMap = [
|
||||
'last_run_at' => 'last_run_at',
|
||||
'last_error' => 'last_error',
|
||||
'last_synced_updated_at' => $updatedAtColumn,
|
||||
'last_synced_source_order_id' => $sourceOrderIdColumn,
|
||||
];
|
||||
|
||||
foreach ($columnMap as $inputKey => $columnName) {
|
||||
if (!array_key_exists($inputKey, $changes)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$paramName = $inputKey;
|
||||
$insertColumns[] = $columnName;
|
||||
$insertValues[] = ':' . $paramName;
|
||||
$updateParts[] = $columnName . ' = VALUES(' . $columnName . ')';
|
||||
$params[$paramName] = $changes[$inputKey];
|
||||
}
|
||||
|
||||
if ($setSuccessAt && $columns['has_last_success_at']) {
|
||||
$insertColumns[] = 'last_success_at';
|
||||
$insertValues[] = ':last_success_at';
|
||||
$updateParts[] = 'last_success_at = VALUES(last_success_at)';
|
||||
$params['last_success_at'] = date('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
try {
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO integration_order_sync_state (' . implode(', ', $insertColumns) . ')
|
||||
VALUES (' . implode(', ', $insertValues) . ')
|
||||
ON DUPLICATE KEY UPDATE ' . implode(', ', $updateParts)
|
||||
);
|
||||
$statement->execute($params);
|
||||
} catch (Throwable) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* has_table:bool,
|
||||
* updated_at_column:?string,
|
||||
* source_order_id_column:?string,
|
||||
* has_last_success_at:bool
|
||||
* }
|
||||
*/
|
||||
private function resolveColumns(): array
|
||||
{
|
||||
if ($this->columns !== null) {
|
||||
return $this->columns;
|
||||
}
|
||||
|
||||
$result = [
|
||||
'has_table' => false,
|
||||
'updated_at_column' => null,
|
||||
'source_order_id_column' => null,
|
||||
'has_last_success_at' => false,
|
||||
];
|
||||
|
||||
try {
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT COLUMN_NAME
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = "integration_order_sync_state"'
|
||||
);
|
||||
$statement->execute();
|
||||
$rows = $statement->fetchAll(PDO::FETCH_COLUMN);
|
||||
} catch (Throwable) {
|
||||
$this->columns = $result;
|
||||
return $result;
|
||||
}
|
||||
|
||||
if (!is_array($rows) || $rows === []) {
|
||||
$this->columns = $result;
|
||||
return $result;
|
||||
}
|
||||
|
||||
$available = [];
|
||||
foreach ($rows as $columnName) {
|
||||
$name = trim((string) $columnName);
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$available[$name] = true;
|
||||
}
|
||||
|
||||
$result['has_table'] = true;
|
||||
if (isset($available['last_synced_order_updated_at'])) {
|
||||
$result['updated_at_column'] = 'last_synced_order_updated_at';
|
||||
} elseif (isset($available['last_synced_external_updated_at'])) {
|
||||
$result['updated_at_column'] = 'last_synced_external_updated_at';
|
||||
}
|
||||
|
||||
if (isset($available['last_synced_source_order_id'])) {
|
||||
$result['source_order_id_column'] = 'last_synced_source_order_id';
|
||||
} elseif (isset($available['last_synced_external_order_id'])) {
|
||||
$result['source_order_id_column'] = 'last_synced_external_order_id';
|
||||
}
|
||||
|
||||
$result['has_last_success_at'] = isset($available['last_success_at']);
|
||||
$this->columns = $result;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* last_synced_updated_at:?string,
|
||||
* last_synced_source_order_id:?string,
|
||||
* last_run_at:?string,
|
||||
* last_success_at:?string,
|
||||
* last_error:?string
|
||||
* }
|
||||
*/
|
||||
private function defaultState(): array
|
||||
{
|
||||
return [
|
||||
'last_synced_updated_at' => null,
|
||||
'last_synced_source_order_id' => null,
|
||||
'last_run_at' => null,
|
||||
'last_success_at' => null,
|
||||
'last_error' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function nullableString(string $value): ?string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
return $trimmed === '' ? null : $trimmed;
|
||||
}
|
||||
}
|
||||
333
src/Modules/Settings/AllegroOrdersSyncService.php
Normal file
333
src/Modules/Settings/AllegroOrdersSyncService.php
Normal file
@@ -0,0 +1,333 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
180
src/Modules/Settings/AllegroStatusDiscoveryService.php
Normal file
180
src/Modules/Settings/AllegroStatusDiscoveryService.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class AllegroStatusDiscoveryService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AllegroIntegrationRepository $integrationRepository,
|
||||
private readonly AllegroOAuthClient $oauthClient,
|
||||
private readonly AllegroApiClient $apiClient,
|
||||
private readonly AllegroStatusMappingRepository $statusMappings
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{discovered:int, samples:int}
|
||||
*/
|
||||
public function discoverAndStoreStatuses(int $maxPages = 3, int $pageLimit = 100): array
|
||||
{
|
||||
$oauth = $this->requireOAuthData();
|
||||
[$accessToken, $oauth] = $this->resolveAccessToken($oauth);
|
||||
|
||||
$unique = [];
|
||||
$safePages = max(1, min(10, $maxPages));
|
||||
$safeLimit = max(1, min(100, $pageLimit));
|
||||
$offset = 0;
|
||||
$samples = 0;
|
||||
|
||||
for ($page = 0; $page < $safePages; $page++) {
|
||||
try {
|
||||
$response = $this->apiClient->listCheckoutForms(
|
||||
(string) ($oauth['environment'] ?? 'sandbox'),
|
||||
$accessToken,
|
||||
$safeLimit,
|
||||
$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,
|
||||
$safeLimit,
|
||||
$offset
|
||||
);
|
||||
}
|
||||
|
||||
$forms = is_array($response['checkoutForms'] ?? null) ? $response['checkoutForms'] : [];
|
||||
if ($forms === []) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($forms as $form) {
|
||||
if (!is_array($form)) {
|
||||
continue;
|
||||
}
|
||||
$rawStatus = strtolower(trim((string) ($form['fulfillment']['status'] ?? $form['status'] ?? '')));
|
||||
if ($rawStatus === '') {
|
||||
continue;
|
||||
}
|
||||
$samples++;
|
||||
$unique[$rawStatus] = $this->prettifyStatusName($rawStatus);
|
||||
}
|
||||
|
||||
if (count($forms) < $safeLimit) {
|
||||
break;
|
||||
}
|
||||
|
||||
$offset += $safeLimit;
|
||||
}
|
||||
|
||||
foreach ($unique as $code => $name) {
|
||||
$this->statusMappings->upsertDiscoveredStatus((string) $code, (string) $name);
|
||||
}
|
||||
|
||||
return [
|
||||
'discovered' => count($unique),
|
||||
'samples' => $samples,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 prettifyStatusName(string $statusCode): string
|
||||
{
|
||||
$normalized = str_replace(['_', '-'], ' ', strtolower(trim($statusCode)));
|
||||
return ucfirst($normalized);
|
||||
}
|
||||
}
|
||||
131
src/Modules/Settings/AllegroStatusMappingRepository.php
Normal file
131
src/Modules/Settings/AllegroStatusMappingRepository.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class AllegroStatusMappingRepository
|
||||
{
|
||||
public function __construct(private readonly PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function listMappings(): array
|
||||
{
|
||||
$statement = $this->pdo->query(
|
||||
'SELECT id, allegro_status_code, allegro_status_name, orderpro_status_code, created_at, updated_at
|
||||
FROM allegro_order_status_mappings
|
||||
ORDER BY allegro_status_code ASC'
|
||||
);
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(static function (array $row): array {
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'allegro_status_code' => strtolower(trim((string) ($row['allegro_status_code'] ?? ''))),
|
||||
'allegro_status_name' => trim((string) ($row['allegro_status_name'] ?? '')),
|
||||
'orderpro_status_code' => strtolower(trim((string) ($row['orderpro_status_code'] ?? ''))),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
'updated_at' => (string) ($row['updated_at'] ?? ''),
|
||||
];
|
||||
}, $rows);
|
||||
}
|
||||
|
||||
public function upsertMapping(string $allegroStatusCode, ?string $allegroStatusName, ?string $orderproStatusCode): void
|
||||
{
|
||||
$code = strtolower(trim($allegroStatusCode));
|
||||
$orderproCode = $orderproStatusCode !== null ? strtolower(trim($orderproStatusCode)) : null;
|
||||
if ($code === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO allegro_order_status_mappings (
|
||||
allegro_status_code, allegro_status_name, orderpro_status_code, created_at, updated_at
|
||||
) VALUES (
|
||||
:allegro_status_code, :allegro_status_name, :orderpro_status_code, NOW(), NOW()
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
allegro_status_name = VALUES(allegro_status_name),
|
||||
orderpro_status_code = VALUES(orderpro_status_code),
|
||||
updated_at = VALUES(updated_at)'
|
||||
);
|
||||
$statement->execute([
|
||||
'allegro_status_code' => $code,
|
||||
'allegro_status_name' => $this->nullableString((string) $allegroStatusName),
|
||||
'orderpro_status_code' => $orderproCode !== null && $orderproCode !== '' ? $orderproCode : null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function upsertDiscoveredStatus(string $allegroStatusCode, ?string $allegroStatusName): void
|
||||
{
|
||||
$code = strtolower(trim($allegroStatusCode));
|
||||
if ($code === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO allegro_order_status_mappings (
|
||||
allegro_status_code, allegro_status_name, orderpro_status_code, created_at, updated_at
|
||||
) VALUES (
|
||||
:allegro_status_code, :allegro_status_name, NULL, NOW(), NOW()
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
allegro_status_name = CASE
|
||||
WHEN VALUES(allegro_status_name) IS NULL OR VALUES(allegro_status_name) = "" THEN allegro_status_name
|
||||
ELSE VALUES(allegro_status_name)
|
||||
END,
|
||||
updated_at = VALUES(updated_at)'
|
||||
);
|
||||
$statement->execute([
|
||||
'allegro_status_code' => $code,
|
||||
'allegro_status_name' => $this->nullableString((string) $allegroStatusName),
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteMappingById(int $id): void
|
||||
{
|
||||
if ($id <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare('DELETE FROM allegro_order_status_mappings WHERE id = :id');
|
||||
$statement->execute(['id' => $id]);
|
||||
}
|
||||
|
||||
public function findMappedOrderproStatusCode(string $allegroStatusCode): ?string
|
||||
{
|
||||
$code = strtolower(trim($allegroStatusCode));
|
||||
if ($code === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT orderpro_status_code
|
||||
FROM allegro_order_status_mappings
|
||||
WHERE allegro_status_code = :allegro_status_code
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute(['allegro_status_code' => $code]);
|
||||
$value = $statement->fetchColumn();
|
||||
if (!is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mapped = strtolower(trim($value));
|
||||
return $mapped !== '' ? $mapped : null;
|
||||
}
|
||||
|
||||
private function nullableString(string $value): ?string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
return $trimmed === '' ? null : $trimmed;
|
||||
}
|
||||
}
|
||||
53
src/Modules/Settings/AllegroStatusSyncService.php
Normal file
53
src/Modules/Settings/AllegroStatusSyncService.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use App\Modules\Cron\CronRepository;
|
||||
|
||||
final class AllegroStatusSyncService
|
||||
{
|
||||
private const DIRECTION_ALLEGRO_TO_ORDERPRO = 'allegro_to_orderpro';
|
||||
private const DIRECTION_ORDERPRO_TO_ALLEGRO = 'orderpro_to_allegro';
|
||||
|
||||
public function __construct(
|
||||
private readonly CronRepository $cronRepository,
|
||||
private readonly AllegroOrdersSyncService $ordersSyncService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function sync(): array
|
||||
{
|
||||
$direction = trim($this->cronRepository->getStringSetting(
|
||||
'allegro_status_sync_direction',
|
||||
self::DIRECTION_ALLEGRO_TO_ORDERPRO
|
||||
));
|
||||
if (!in_array($direction, [self::DIRECTION_ALLEGRO_TO_ORDERPRO, self::DIRECTION_ORDERPRO_TO_ALLEGRO], true)) {
|
||||
$direction = self::DIRECTION_ALLEGRO_TO_ORDERPRO;
|
||||
}
|
||||
|
||||
if ($direction === self::DIRECTION_ORDERPRO_TO_ALLEGRO) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'direction' => $direction,
|
||||
'processed' => 0,
|
||||
'message' => 'Kierunek orderPRO -> Allegro nie jest jeszcze wdrozony.',
|
||||
];
|
||||
}
|
||||
|
||||
$ordersResult = $this->ordersSyncService->sync([
|
||||
'max_pages' => 3,
|
||||
'page_limit' => 50,
|
||||
'max_orders' => 100,
|
||||
]);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'direction' => $direction,
|
||||
'orders_sync' => $ordersResult,
|
||||
];
|
||||
}
|
||||
}
|
||||
85
src/Modules/Settings/CronSettingsController.php
Normal file
85
src/Modules/Settings/CronSettingsController.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use App\Core\Http\Request;
|
||||
use App\Core\Http\Response;
|
||||
use App\Core\I18n\Translator;
|
||||
use App\Core\Security\Csrf;
|
||||
use App\Core\Support\Flash;
|
||||
use App\Core\View\Template;
|
||||
use App\Modules\Auth\AuthService;
|
||||
use App\Modules\Cron\CronRepository;
|
||||
use Throwable;
|
||||
|
||||
final class CronSettingsController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Template $template,
|
||||
private readonly Translator $translator,
|
||||
private readonly AuthService $auth,
|
||||
private readonly CronRepository $cronRepository,
|
||||
private readonly bool $runOnWebDefault,
|
||||
private readonly int $webLimitDefault
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$runOnWeb = $this->cronRepository->getBoolSetting('cron_run_on_web', $this->runOnWebDefault);
|
||||
$webLimit = $this->cronRepository->getIntSetting('cron_web_limit', $this->webLimitDefault, 1, 100);
|
||||
$schedules = $this->cronRepository->listSchedules();
|
||||
$futureJobs = $this->cronRepository->listFutureJobs(60);
|
||||
$pastJobs = $this->cronRepository->listPastJobs(60);
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.cron.flash.load_failed') . ' ' . $exception->getMessage());
|
||||
$runOnWeb = $this->runOnWebDefault;
|
||||
$webLimit = $this->webLimitDefault;
|
||||
$schedules = [];
|
||||
$futureJobs = [];
|
||||
$pastJobs = [];
|
||||
}
|
||||
|
||||
$html = $this->template->render('settings/cron', [
|
||||
'title' => $this->translator->get('settings.cron.title'),
|
||||
'activeMenu' => 'settings',
|
||||
'activeSettings' => 'cron',
|
||||
'user' => $this->auth->user(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
'runOnWeb' => $runOnWeb,
|
||||
'webLimit' => $webLimit,
|
||||
'schedules' => $schedules,
|
||||
'futureJobs' => $futureJobs,
|
||||
'pastJobs' => $pastJobs,
|
||||
'errorMessage' => (string) Flash::get('settings_error', ''),
|
||||
'successMessage' => (string) Flash::get('settings_success', ''),
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
$csrfToken = (string) $request->input('_token', '');
|
||||
if (!Csrf::validate($csrfToken)) {
|
||||
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
|
||||
return Response::redirect('/settings/cron');
|
||||
}
|
||||
|
||||
$runOnWeb = (string) $request->input('cron_run_on_web', '0') === '1';
|
||||
$webLimitRaw = (int) $request->input('cron_web_limit', $this->webLimitDefault);
|
||||
$webLimit = max(1, min(100, $webLimitRaw));
|
||||
|
||||
try {
|
||||
$this->cronRepository->upsertSetting('cron_run_on_web', $runOnWeb ? '1' : '0');
|
||||
$this->cronRepository->upsertSetting('cron_web_limit', (string) $webLimit);
|
||||
Flash::set('settings_success', $this->translator->get('settings.cron.flash.saved'));
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.cron.flash.save_failed') . ' ' . $exception->getMessage());
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/cron');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user