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:
2026-03-04 23:21:35 +01:00
parent 9ca79ca8d8
commit 7ac4293df4
40 changed files with 5758 additions and 31 deletions

View File

@@ -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

View 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),
]);
}
}

View 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();
}
}

View 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,
];
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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
*/

View File

@@ -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);

View 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;
}
}

View 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,
];
}
}

View 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 : '';
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
}

View 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;
}
}

View 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,
];
}
}

View 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');
}
}