Phase 129 complete: - Add Erli pull/push status mapping tables, seeds and repositories - Wire Erli status sync cron for inbox pull and manual-only push - Add tabbed Erli settings UI, tests and documentation Co-Authored-By: Claude <noreply@anthropic.com>
352 lines
15 KiB
PHP
352 lines
15 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Settings;
|
|
|
|
use App\Core\Exceptions\IntegrationConfigException;
|
|
use App\Core\Http\RedirectPathResolver;
|
|
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 ErliIntegrationController
|
|
{
|
|
private const ORDERS_IMPORT_JOB_TYPE = 'erli_orders_import';
|
|
private const ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS = 300;
|
|
private const ORDERS_IMPORT_DEFAULT_PRIORITY = 40;
|
|
private const ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS = 3;
|
|
private const STATUS_SYNC_JOB_TYPE = 'erli_status_sync';
|
|
private const STATUS_SYNC_DEFAULT_INTERVAL_SECONDS = 900;
|
|
private const STATUS_SYNC_DEFAULT_PRIORITY = 45;
|
|
private const STATUS_SYNC_DEFAULT_MAX_ATTEMPTS = 3;
|
|
private const STATUS_SYNC_DIRECTION_PULL = 'erli_to_orderpro';
|
|
private const STATUS_SYNC_DIRECTION_PUSH = 'orderpro_to_erli';
|
|
|
|
public function __construct(
|
|
private readonly Template $template,
|
|
private readonly Translator $translator,
|
|
private readonly AuthService $auth,
|
|
private readonly ErliIntegrationRepository $repository,
|
|
private readonly ErliApiClient $apiClient,
|
|
private readonly IntegrationsRepository $integrations,
|
|
private readonly CronRepository $cronRepository,
|
|
private readonly ErliOrdersSyncService $ordersSyncService,
|
|
private readonly OrderStatusRepository $orderStatuses,
|
|
private readonly ErliStatusMappingRepository $statusMappings,
|
|
private readonly ErliPullStatusMappingRepository $pullStatusMappings
|
|
) {
|
|
}
|
|
|
|
public function index(Request $request): Response
|
|
{
|
|
$activeTab = $this->resolveTab((string) $request->input('tab', 'integration'));
|
|
|
|
$html = $this->template->render('settings/erli', [
|
|
'title' => $this->translator->get('settings.erli.title'),
|
|
'activeMenu' => 'settings',
|
|
'activeSettings' => 'integrations',
|
|
'user' => $this->auth->user(),
|
|
'csrfToken' => Csrf::token(),
|
|
'activeTab' => $activeTab,
|
|
'settings' => $this->repository->getSettings(),
|
|
'ordersImportIntervalMinutes' => $this->currentImportIntervalMinutes(),
|
|
'statusSyncDirection' => $this->currentStatusSyncDirection(),
|
|
'statusSyncIntervalMinutes' => $this->currentStatusSyncIntervalMinutes(),
|
|
'orderproStatuses' => $this->orderStatuses->listStatuses(),
|
|
'erliStatusMappings' => $this->statusMappings->listAll(),
|
|
'erliPullStatusMappings' => $this->pullStatusMappings->listAll(),
|
|
'errorMessage' => (string) Flash::get('settings_error', ''),
|
|
'successMessage' => (string) Flash::get('settings_success', ''),
|
|
'testMessage' => (string) Flash::get('erli_test', ''),
|
|
'importMessage' => (string) Flash::get('erli_import', ''),
|
|
], 'layouts/app');
|
|
|
|
return Response::html($html);
|
|
}
|
|
|
|
public function save(Request $request): Response
|
|
{
|
|
$redirectTo = $this->resolveRedirect($request);
|
|
if (!Csrf::validate((string) $request->input('_token', ''))) {
|
|
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
|
|
return Response::redirect($redirectTo);
|
|
}
|
|
|
|
try {
|
|
$this->repository->saveSettings([
|
|
'account_label' => (string) $request->input('account_label', ''),
|
|
'api_key' => (string) $request->input('api_key', ''),
|
|
'is_active' => $request->input('is_active', ''),
|
|
'orders_fetch_enabled' => $request->input('orders_fetch_enabled', ''),
|
|
'orders_fetch_start_date' => $this->validateStartDate((string) $request->input('orders_fetch_start_date', '')),
|
|
]);
|
|
$this->upsertImportSchedule($request);
|
|
$this->saveStatusSyncSettings($request);
|
|
Flash::set('settings_success', $this->translator->get('settings.erli.flash.saved'));
|
|
} catch (Throwable $exception) {
|
|
Flash::set(
|
|
'settings_error',
|
|
$this->translator->get('settings.erli.flash.save_failed') . ' ' . $exception->getMessage()
|
|
);
|
|
}
|
|
|
|
return Response::redirect($redirectTo);
|
|
}
|
|
|
|
public function savePullStatusMappings(Request $request): Response
|
|
{
|
|
$redirectTo = $this->resolveRedirect($request);
|
|
if (!Csrf::validate((string) $request->input('_token', ''))) {
|
|
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
|
|
return Response::redirect($redirectTo);
|
|
}
|
|
|
|
$erliCodes = $request->input('erli_status_code', []);
|
|
$erliNames = $request->input('erli_status_name', []);
|
|
$orderproCodes = $request->input('orderpro_status_code', []);
|
|
if (!is_array($erliCodes) || !is_array($erliNames) || !is_array($orderproCodes)) {
|
|
Flash::set('settings_error', $this->translator->get('settings.erli.statuses.flash.save_failed'));
|
|
return Response::redirect($redirectTo);
|
|
}
|
|
|
|
$mappings = [];
|
|
foreach ($erliCodes as $index => $rawErliCode) {
|
|
$erliCode = strtolower(trim((string) $rawErliCode));
|
|
if ($erliCode === '') {
|
|
continue;
|
|
}
|
|
$mappings[] = [
|
|
'erli_status_code' => $erliCode,
|
|
'erli_status_name' => trim((string) ($erliNames[$index] ?? '')),
|
|
'orderpro_status_code' => strtolower(trim((string) ($orderproCodes[$index] ?? ''))),
|
|
];
|
|
}
|
|
|
|
try {
|
|
$this->pullStatusMappings->replaceAll($mappings);
|
|
Flash::set('settings_success', $this->translator->get('settings.erli.statuses.flash.saved_pull'));
|
|
} catch (Throwable $exception) {
|
|
Flash::set('settings_error', $this->translator->get('settings.erli.statuses.flash.save_failed') . ' ' . $exception->getMessage());
|
|
}
|
|
|
|
return Response::redirect($redirectTo);
|
|
}
|
|
|
|
public function savePushStatusMappings(Request $request): Response
|
|
{
|
|
$redirectTo = $this->resolveRedirect($request);
|
|
if (!Csrf::validate((string) $request->input('_token', ''))) {
|
|
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
|
|
return Response::redirect($redirectTo);
|
|
}
|
|
|
|
$erliCodes = $request->input('erli_status_code', []);
|
|
$erliNames = $request->input('erli_status_name', []);
|
|
$orderproCodes = $request->input('orderpro_status_code', []);
|
|
if (!is_array($erliCodes) || !is_array($erliNames) || !is_array($orderproCodes)) {
|
|
Flash::set('settings_error', $this->translator->get('settings.erli.statuses.flash.save_failed'));
|
|
return Response::redirect($redirectTo);
|
|
}
|
|
|
|
$mappings = [];
|
|
foreach ($erliCodes as $index => $rawErliCode) {
|
|
$erliCode = trim((string) $rawErliCode);
|
|
if ($erliCode === '') {
|
|
continue;
|
|
}
|
|
$mappings[] = [
|
|
'erli_status_code' => $erliCode,
|
|
'erli_status_name' => trim((string) ($erliNames[$index] ?? '')),
|
|
'orderpro_status_code' => strtolower(trim((string) ($orderproCodes[$index] ?? ''))),
|
|
];
|
|
}
|
|
|
|
try {
|
|
$this->statusMappings->replaceAll($mappings);
|
|
Flash::set('settings_success', $this->translator->get('settings.erli.statuses.flash.saved_push'));
|
|
} catch (Throwable $exception) {
|
|
Flash::set('settings_error', $this->translator->get('settings.erli.statuses.flash.save_failed') . ' ' . $exception->getMessage());
|
|
}
|
|
|
|
return Response::redirect($redirectTo);
|
|
}
|
|
|
|
public function importNow(Request $request): Response
|
|
{
|
|
$redirectTo = $this->resolveRedirect($request);
|
|
if (!Csrf::validate((string) $request->input('_token', ''))) {
|
|
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
|
|
return Response::redirect($redirectTo);
|
|
}
|
|
|
|
try {
|
|
$result = $this->ordersSyncService->sync([
|
|
'ignore_orders_fetch_enabled' => true,
|
|
'max_messages' => 100,
|
|
]);
|
|
Flash::set('erli_import', $this->formatImportResult($result));
|
|
} catch (Throwable $exception) {
|
|
Flash::set('settings_error', $this->translator->get('settings.erli.flash.import_failed') . ' ' . $exception->getMessage());
|
|
}
|
|
|
|
return Response::redirect($redirectTo);
|
|
}
|
|
|
|
public function test(Request $request): Response
|
|
{
|
|
$redirectTo = $this->resolveRedirect($request);
|
|
if (!Csrf::validate((string) $request->input('_token', ''))) {
|
|
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
|
|
return Response::redirect($redirectTo);
|
|
}
|
|
|
|
try {
|
|
$credentials = $this->repository->getCredentials();
|
|
if ($credentials === null) {
|
|
throw new IntegrationConfigException('Najpierw zapisz kompletna i aktywna konfiguracje Erli.');
|
|
}
|
|
|
|
$result = $this->apiClient->testConnection($credentials);
|
|
$this->integrations->updateTestResult(
|
|
$credentials['integration_id'],
|
|
$result['ok'] ? 'ok' : 'fail',
|
|
(int) $result['http_code'],
|
|
(string) $result['message']
|
|
);
|
|
|
|
if ($result['ok']) {
|
|
Flash::set('erli_test', $this->translator->get('settings.erli.flash.test_success'));
|
|
} else {
|
|
Flash::set('settings_error', $this->translator->get('settings.erli.flash.test_failed') . ' ' . $result['message']);
|
|
}
|
|
} catch (Throwable $exception) {
|
|
Flash::set('settings_error', $this->translator->get('settings.erli.flash.test_failed') . ' ' . $exception->getMessage());
|
|
}
|
|
|
|
return Response::redirect($redirectTo);
|
|
}
|
|
|
|
private function resolveRedirect(Request $request): string
|
|
{
|
|
return RedirectPathResolver::resolve(
|
|
(string) $request->input('return_to', '/settings/integrations/erli'),
|
|
['/settings/integrations'],
|
|
'/settings/integrations/erli'
|
|
);
|
|
}
|
|
|
|
private function resolveTab(string $tab): string
|
|
{
|
|
$normalized = trim($tab);
|
|
if (in_array($normalized, ['integration', 'statuses', 'settings'], true)) {
|
|
return $normalized;
|
|
}
|
|
|
|
return 'integration';
|
|
}
|
|
|
|
private function validateStartDate(string $value): string
|
|
{
|
|
$trimmed = trim($value);
|
|
if ($trimmed === '') {
|
|
return '';
|
|
}
|
|
|
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $trimmed) !== 1) {
|
|
throw new IntegrationConfigException($this->translator->get('settings.erli.validation.orders_fetch_start_date_invalid'));
|
|
}
|
|
|
|
return $trimmed;
|
|
}
|
|
|
|
private function upsertImportSchedule(Request $request): void
|
|
{
|
|
$minutes = max(1, min(1440, (int) $request->input('orders_import_interval_minutes', 5)));
|
|
$enabled = (string) $request->input('orders_fetch_enabled', '') === '1';
|
|
$this->cronRepository->upsertSchedule(
|
|
self::ORDERS_IMPORT_JOB_TYPE,
|
|
$minutes * 60,
|
|
self::ORDERS_IMPORT_DEFAULT_PRIORITY,
|
|
self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS,
|
|
null,
|
|
$enabled
|
|
);
|
|
}
|
|
|
|
private function saveStatusSyncSettings(Request $request): void
|
|
{
|
|
$direction = trim((string) $request->input('status_sync_direction', self::STATUS_SYNC_DIRECTION_PULL));
|
|
if (!in_array($direction, [self::STATUS_SYNC_DIRECTION_PULL, self::STATUS_SYNC_DIRECTION_PUSH], true)) {
|
|
throw new IntegrationConfigException($this->translator->get('settings.erli.validation.status_sync_direction_invalid'));
|
|
}
|
|
|
|
$minutes = max(1, min(1440, (int) $request->input('status_sync_interval_minutes', 15)));
|
|
$enabled = (string) $request->input('is_active', '') === '1';
|
|
$this->cronRepository->upsertSetting('erli_status_sync_direction', $direction);
|
|
$this->cronRepository->upsertSetting('erli_status_sync_interval_minutes', (string) $minutes);
|
|
$this->cronRepository->upsertSchedule(
|
|
self::STATUS_SYNC_JOB_TYPE,
|
|
$minutes * 60,
|
|
self::STATUS_SYNC_DEFAULT_PRIORITY,
|
|
self::STATUS_SYNC_DEFAULT_MAX_ATTEMPTS,
|
|
null,
|
|
$enabled
|
|
);
|
|
}
|
|
|
|
private function currentImportIntervalMinutes(): int
|
|
{
|
|
foreach ($this->cronRepository->listSchedules() as $schedule) {
|
|
if ((string) ($schedule['job_type'] ?? '') !== self::ORDERS_IMPORT_JOB_TYPE) {
|
|
continue;
|
|
}
|
|
$seconds = (int) ($schedule['interval_seconds'] ?? self::ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS);
|
|
return max(1, min(1440, (int) floor(max(60, $seconds) / 60)));
|
|
}
|
|
|
|
return 5;
|
|
}
|
|
|
|
private function currentStatusSyncDirection(): string
|
|
{
|
|
$direction = trim($this->cronRepository->getStringSetting('erli_status_sync_direction', self::STATUS_SYNC_DIRECTION_PULL));
|
|
return in_array($direction, [self::STATUS_SYNC_DIRECTION_PULL, self::STATUS_SYNC_DIRECTION_PUSH], true)
|
|
? $direction
|
|
: self::STATUS_SYNC_DIRECTION_PULL;
|
|
}
|
|
|
|
private function currentStatusSyncIntervalMinutes(): int
|
|
{
|
|
foreach ($this->cronRepository->listSchedules() as $schedule) {
|
|
if ((string) ($schedule['job_type'] ?? '') !== self::STATUS_SYNC_JOB_TYPE) {
|
|
continue;
|
|
}
|
|
$seconds = (int) ($schedule['interval_seconds'] ?? self::STATUS_SYNC_DEFAULT_INTERVAL_SECONDS);
|
|
return max(1, min(1440, (int) floor(max(60, $seconds) / 60)));
|
|
}
|
|
|
|
return $this->cronRepository->getIntSetting('erli_status_sync_interval_minutes', 15, 1, 1440);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $result
|
|
*/
|
|
private function formatImportResult(array $result): string
|
|
{
|
|
return strtr($this->translator->get('settings.erli.flash.import_success'), [
|
|
':processed' => (string) (int) ($result['processed'] ?? 0),
|
|
':created' => (string) (int) ($result['imported_created'] ?? 0),
|
|
':updated' => (string) (int) ($result['imported_updated'] ?? 0),
|
|
':failed' => (string) (int) ($result['failed'] ?? 0),
|
|
':skipped' => (string) (int) ($result['skipped'] ?? 0),
|
|
':ack' => !empty($result['acknowledged']) ? 'tak' : 'nie',
|
|
]);
|
|
}
|
|
}
|