feat(129): erli status mapping sync

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>
This commit is contained in:
2026-05-16 00:27:08 +02:00
parent c127ebf04d
commit 7972bb9fa4
28 changed files with 2021 additions and 57 deletions

View File

@@ -21,6 +21,12 @@ final class ErliIntegrationController
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,
@@ -30,20 +36,31 @@ final class ErliIntegrationController
private readonly ErliApiClient $apiClient,
private readonly IntegrationsRepository $integrations,
private readonly CronRepository $cronRepository,
private readonly ErliOrdersSyncService $ordersSyncService
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', ''),
@@ -70,6 +87,7 @@ final class ErliIntegrationController
'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(
@@ -81,6 +99,84 @@ final class ErliIntegrationController
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);
@@ -145,6 +241,16 @@ final class ErliIntegrationController
);
}
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);
@@ -173,6 +279,27 @@ final class ErliIntegrationController
);
}
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) {
@@ -186,6 +313,27 @@ final class ErliIntegrationController
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
*/