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

@@ -5,6 +5,7 @@ namespace Tests\Unit;
use App\Core\Constants\IntegrationSources;
use App\Modules\Settings\ErliOrderMapper;
use App\Modules\Settings\ErliPullStatusMappingRepository;
use PHPUnit\Framework\TestCase;
use RuntimeException;
@@ -51,6 +52,30 @@ final class ErliOrderMapperTest extends TestCase
self::assertTrue($aggregate['order']['is_canceled_by_buyer']);
}
public function testConfiguredPullMappingOverridesDefaultStatus(): void
{
$pullMappings = $this->createMock(ErliPullStatusMappingRepository::class);
$pullMappings
->method('findMappedStatusCode')
->with('purchased')
->willReturn('w_realizacji');
$mapper = new ErliOrderMapper($pullMappings);
$aggregate = $mapper->mapInboxMessage(7, $this->message('purchased'));
self::assertIsArray($aggregate);
self::assertSame('w_realizacji', $aggregate['order']['status_code']);
}
public function testUnknownStatusFallsBackToRawCodeForDiscoveryMappingLater(): void
{
$aggregate = $this->mapper->mapInboxMessage(7, $this->message('readyToProcess'));
self::assertIsArray($aggregate);
self::assertSame('readytoprocess', $aggregate['order']['status_code']);
self::assertSame('readytoprocess', $aggregate['order']['preferences_json']['erli_status_raw']);
}
public function testCompanyInvoiceDataDetectsInvoiceRequest(): void
{
$message = $this->message('purchased');

View File

@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Modules\Cron\CronRepository;
use App\Modules\Settings\ErliApiClient;
use App\Modules\Settings\ErliIntegrationRepository;
use App\Modules\Settings\ErliOrdersSyncService;
use App\Modules\Settings\ErliOrderSyncStateRepository;
use App\Modules\Settings\ErliStatusMappingRepository;
use App\Modules\Settings\ErliStatusSyncService;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class ErliStatusSyncServiceTest extends TestCase
{
private CronRepository&MockObject $cronRepository;
private ErliIntegrationRepository&MockObject $integrationRepository;
private ErliOrdersSyncService&MockObject $ordersSyncService;
private ErliApiClient&MockObject $apiClient;
private ErliOrderSyncStateRepository&MockObject $syncStateRepository;
private ErliStatusMappingRepository&MockObject $statusMappings;
protected function setUp(): void
{
$this->cronRepository = $this->createMock(CronRepository::class);
$this->integrationRepository = $this->createMock(ErliIntegrationRepository::class);
$this->ordersSyncService = $this->createMock(ErliOrdersSyncService::class);
$this->apiClient = $this->createMock(ErliApiClient::class);
$this->syncStateRepository = $this->createMock(ErliOrderSyncStateRepository::class);
$this->statusMappings = $this->createMock(ErliStatusMappingRepository::class);
}
public function testPullDirectionDelegatesToOrdersInboxImport(): void
{
$this->cronRepository
->method('getStringSetting')
->willReturn('erli_to_orderpro');
$this->ordersSyncService
->expects($this->once())
->method('sync')
->with([
'ignore_orders_fetch_enabled' => true,
'max_messages' => 200,
])
->willReturn([
'failed' => 0,
'processed' => 2,
]);
$result = $this->createServiceWithRows([])->sync();
self::assertTrue($result['ok']);
self::assertSame('erli_to_orderpro', $result['direction']);
self::assertSame(2, $result['processed']);
}
public function testPushDirectionProcessesMappedManualOrdersAndSkipsUnmapped(): void
{
$this->preparePushDefaults([
'w_realizacji' => 'inProgress',
]);
$this->apiClient
->expects($this->exactly(2))
->method('updateOrderStatus')
->willReturn(['ok' => true, 'http_code' => 204, 'message' => 'OK']);
$this->syncStateRepository
->expects($this->once())
->method('updateLastStatusPushedAt')
->with(12, '2026-05-16 10:10:00');
$service = $this->createServiceWithRows([
['source_order_id' => 'E1', 'orderpro_status_code' => 'w_realizacji', 'latest_change' => '2026-05-16 10:00:00'],
['source_order_id' => 'E2', 'orderpro_status_code' => 'nowe', 'latest_change' => '2026-05-16 10:05:00'],
['source_order_id' => 'E3', 'orderpro_status_code' => 'w_realizacji', 'latest_change' => '2026-05-16 10:10:00'],
]);
$result = $service->sync();
self::assertTrue($result['ok']);
self::assertSame('orderpro_to_erli', $result['direction']);
self::assertSame(2, $result['pushed']);
self::assertSame(1, $result['skipped']);
self::assertSame(0, $result['failed']);
}
public function testPushFailureDoesNotAdvanceCursorPastFailedOrder(): void
{
$this->preparePushDefaults([
'w_realizacji' => 'inProgress',
]);
$this->apiClient
->expects($this->exactly(2))
->method('updateOrderStatus')
->willReturnOnConsecutiveCalls(
['ok' => true, 'http_code' => 204, 'message' => 'OK'],
['ok' => false, 'http_code' => 500, 'message' => 'Erli error']
);
$this->syncStateRepository
->expects($this->once())
->method('updateLastStatusPushedAt')
->with(12, '2026-05-16 10:00:00');
$service = $this->createServiceWithRows([
['source_order_id' => 'E1', 'orderpro_status_code' => 'w_realizacji', 'latest_change' => '2026-05-16 10:00:00'],
['source_order_id' => 'E2', 'orderpro_status_code' => 'w_realizacji', 'latest_change' => '2026-05-16 10:10:00'],
]);
$result = $service->sync();
self::assertFalse($result['ok']);
self::assertSame(1, $result['pushed']);
self::assertSame(1, $result['failed']);
self::assertCount(1, $result['errors']);
}
public function testPushDirectionReturnsEarlyWithoutCredentials(): void
{
$this->cronRepository
->method('getStringSetting')
->willReturn('orderpro_to_erli');
$this->integrationRepository
->method('getCredentials')
->willReturn(null);
$this->apiClient
->expects($this->never())
->method('updateOrderStatus');
$result = $this->createServiceWithRows([])->sync();
self::assertFalse($result['ok']);
self::assertSame(0, $result['pushed']);
self::assertSame('orderpro_to_erli', $result['direction']);
}
/**
* @param array<string, string> $map
*/
private function preparePushDefaults(array $map): void
{
$this->cronRepository
->method('getStringSetting')
->willReturn('orderpro_to_erli');
$this->integrationRepository
->method('getCredentials')
->willReturn([
'integration_id' => 12,
'base_url' => 'https://erli.test',
'api_key' => 'token',
'timeout_seconds' => 15,
'orders_fetch_enabled' => true,
'orders_fetch_start_date' => null,
]);
$this->syncStateRepository
->method('getLastStatusPushedAt')
->willReturn(null);
$this->statusMappings
->method('buildOrderproToErliMap')
->willReturn($map);
}
/**
* @param array<int, array<string, mixed>> $rows
*/
private function createServiceWithRows(array $rows): ErliStatusSyncService
{
$statement = $this->createMock(PDOStatement::class);
$statement
->method('execute')
->willReturn(true);
$statement
->method('fetchAll')
->willReturn($rows);
$pdo = $this->createMock(PDO::class);
$pdo
->method('prepare')
->willReturn($statement);
return new ErliStatusSyncService(
$this->cronRepository,
$this->integrationRepository,
$this->ordersSyncService,
$this->apiClient,
$this->syncStateRepository,
$this->statusMappings,
$pdo
);
}
}