feat(29-delivery-status-mapping-ui): konfiguracja mapowania statusów dostawy per provider

Phase 29 complete (v1.3):
- Tabela delivery_status_mappings z DB overrides
- DeliveryStatus: normalizeWithOverrides(), descriptionWithOverrides(), getDefaultMappings()
- UI ustawień: tabela mapowań per provider (InPost/Apaczka/Allegro), bulk save, reset, resetAll
- 5 endpointów w routes/web.php, link w menu bocznym

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 23:55:42 +01:00
parent 98a0077204
commit 325a941c42
14 changed files with 1058 additions and 15 deletions

View File

@@ -0,0 +1,222 @@
<?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\Shipments\DeliveryStatus;
use App\Modules\Shipments\DeliveryStatusMappingRepository;
use Throwable;
final class DeliveryStatusMappingController
{
private const REDIRECT_PATH = '/settings/delivery-status-mappings';
private const PROVIDERS = [
'inpost' => 'InPost',
'apaczka' => 'Apaczka',
'allegro_wza' => 'Allegro',
];
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly DeliveryStatusMappingRepository $repository
) {
}
public function index(Request $request): Response
{
$provider = strtolower(trim((string) $request->input('provider', 'inpost')));
if (!isset(self::PROVIDERS[$provider])) {
$provider = 'inpost';
}
$defaults = DeliveryStatus::getDefaultMappings($provider);
$overrides = $this->repository->listByProvider($provider);
$overrideMap = [];
foreach ($overrides as $row) {
$overrideMap[$row['raw_status']] = $row;
}
$mappings = [];
foreach ($defaults as $rawStatus => $default) {
$isCustom = isset($overrideMap[$rawStatus]);
$mappings[] = [
'raw_status' => $rawStatus,
'description' => $isCustom ? $overrideMap[$rawStatus]['description'] : $default['description'],
'normalized_status' => $isCustom ? $overrideMap[$rawStatus]['normalized_status'] : $default['normalized'],
'is_custom' => $isCustom,
];
}
$html = $this->template->render('settings/delivery-status-mappings', [
'title' => 'Mapowanie statusów dostawy',
'activeMenu' => 'settings',
'activeSettings' => 'delivery-status-mappings',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'provider' => $provider,
'providers' => self::PROVIDERS,
'mappings' => $mappings,
'normalizedOptions' => DeliveryStatus::LABEL_PL,
'errorMessage' => (string) Flash::get('dsm_error', ''),
'successMessage' => (string) Flash::get('dsm_success', ''),
], 'layouts/app');
return Response::html($html);
}
public function save(Request $request): Response
{
$csrfError = $this->validateCsrf((string) $request->input('_token', ''));
if ($csrfError !== null) {
return $csrfError;
}
$provider = strtolower(trim((string) $request->input('provider', '')));
$rawStatus = trim((string) $request->input('raw_status', ''));
$normalizedStatus = trim((string) $request->input('normalized_status', ''));
$description = trim((string) $request->input('description', ''));
if ($provider === '' || $rawStatus === '') {
Flash::set('dsm_error', 'Brakuje wymaganych pól.');
return Response::redirect(self::REDIRECT_PATH . '?provider=' . rawurlencode($provider));
}
if (!in_array($normalizedStatus, DeliveryStatus::ALL_STATUSES, true)) {
Flash::set('dsm_error', 'Nieprawidłowy status znormalizowany.');
return Response::redirect(self::REDIRECT_PATH . '?provider=' . rawurlencode($provider));
}
try {
$this->repository->upsertMapping($provider, $rawStatus, $normalizedStatus, $description);
Flash::set('dsm_success', 'Mapowanie zapisane.');
} catch (Throwable $exception) {
Flash::set('dsm_error', 'Błąd zapisu: ' . $exception->getMessage());
}
return Response::redirect(self::REDIRECT_PATH . '?provider=' . rawurlencode($provider));
}
public function saveBulk(Request $request): Response
{
$csrfError = $this->validateCsrf((string) $request->input('_token', ''));
if ($csrfError !== null) {
return $csrfError;
}
$provider = strtolower(trim((string) $request->input('provider', '')));
$rawStatuses = $request->input('raw_status', []);
$normalizedStatuses = $request->input('normalized_status', []);
$descriptions = $request->input('description', []);
if (!is_array($rawStatuses) || !is_array($normalizedStatuses) || !is_array($descriptions)) {
Flash::set('dsm_error', 'Nieprawidłowe dane formularza.');
return Response::redirect(self::REDIRECT_PATH . '?provider=' . rawurlencode($provider));
}
try {
$changed = 0;
$defaults = DeliveryStatus::getDefaultMappings($provider);
foreach ($rawStatuses as $index => $rawStatus) {
$rawStatus = trim((string) $rawStatus);
if ($rawStatus === '') {
continue;
}
$normalizedStatus = trim((string) ($normalizedStatuses[$index] ?? ''));
$description = trim((string) ($descriptions[$index] ?? ''));
if (!in_array($normalizedStatus, DeliveryStatus::ALL_STATUSES, true)) {
continue;
}
$default = $defaults[$rawStatus] ?? null;
$isDefault = $default !== null
&& $normalizedStatus === $default['normalized']
&& $description === $default['description'];
if ($isDefault) {
$this->repository->deleteMapping($provider, $rawStatus);
} else {
$this->repository->upsertMapping($provider, $rawStatus, $normalizedStatus, $description);
$changed++;
}
}
Flash::set('dsm_success', 'Zapisano zmiany (' . $changed . ' niestandardowych mapowań).');
} catch (Throwable $exception) {
Flash::set('dsm_error', 'Błąd zapisu: ' . $exception->getMessage());
}
return Response::redirect(self::REDIRECT_PATH . '?provider=' . rawurlencode($provider));
}
public function reset(Request $request): Response
{
$csrfError = $this->validateCsrf((string) $request->input('_token', ''));
if ($csrfError !== null) {
return $csrfError;
}
$provider = strtolower(trim((string) $request->input('provider', '')));
$rawStatus = trim((string) $request->input('raw_status', ''));
if ($provider === '' || $rawStatus === '') {
Flash::set('dsm_error', 'Brakuje wymaganych pól.');
return Response::redirect(self::REDIRECT_PATH . '?provider=' . rawurlencode($provider));
}
try {
$this->repository->deleteMapping($provider, $rawStatus);
Flash::set('dsm_success', 'Przywrócono domyślne mapowanie.');
} catch (Throwable $exception) {
Flash::set('dsm_error', 'Błąd resetu: ' . $exception->getMessage());
}
return Response::redirect(self::REDIRECT_PATH . '?provider=' . rawurlencode($provider));
}
public function resetAll(Request $request): Response
{
$csrfError = $this->validateCsrf((string) $request->input('_token', ''));
if ($csrfError !== null) {
return $csrfError;
}
$provider = strtolower(trim((string) $request->input('provider', '')));
if ($provider === '') {
Flash::set('dsm_error', 'Brakuje wymaganych pól.');
return Response::redirect(self::REDIRECT_PATH);
}
try {
$this->repository->deleteAllByProvider($provider);
Flash::set('dsm_success', 'Przywrócono wszystkie domyślne mapowania.');
} catch (Throwable $exception) {
Flash::set('dsm_error', 'Błąd resetu: ' . $exception->getMessage());
}
return Response::redirect(self::REDIRECT_PATH . '?provider=' . rawurlencode($provider));
}
private function validateCsrf(string $token): ?Response
{
if (Csrf::validate($token)) {
return null;
}
Flash::set('dsm_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect(self::REDIRECT_PATH);
}
}

View File

@@ -191,6 +191,76 @@ final class DeliveryStatus
'RETURNED' => 'Zwrócona do nadawcy',
];
public const ALL_STATUSES = [
self::UNKNOWN,
self::CREATED,
self::CONFIRMED,
self::IN_TRANSIT,
self::OUT_FOR_DELIVERY,
self::READY_FOR_PICKUP,
self::DELIVERED,
self::RETURNED,
self::CANCELLED,
self::PROBLEM,
];
private const PROVIDER_MAPS = [
'inpost' => self::INPOST_MAP,
'apaczka' => self::APACZKA_MAP,
'allegro_wza' => self::ALLEGRO_MAP,
];
private const PROVIDER_DESCRIPTIONS = [
'inpost' => self::INPOST_DESCRIPTIONS,
'apaczka' => self::APACZKA_DESCRIPTIONS,
'allegro_wza' => self::ALLEGRO_DESCRIPTIONS,
];
/**
* @return array<string, array{normalized: string, description: string}>
*/
public static function getDefaultMappings(string $provider): array
{
$map = self::PROVIDER_MAPS[$provider] ?? [];
$descriptions = self::PROVIDER_DESCRIPTIONS[$provider] ?? [];
$result = [];
foreach ($map as $rawStatus => $normalized) {
$result[(string) $rawStatus] = [
'normalized' => $normalized,
'description' => (string) ($descriptions[$rawStatus] ?? (string) $rawStatus),
];
}
return $result;
}
/**
* @param array<string, array{normalized_status: string, description: string}> $overrides keyed by "provider:raw_status"
*/
public static function normalizeWithOverrides(string $provider, string $rawStatus, array $overrides): string
{
$key = $provider . ':' . $rawStatus;
if (isset($overrides[$key]) && $overrides[$key]['normalized_status'] !== '') {
return $overrides[$key]['normalized_status'];
}
return self::normalize($provider, $rawStatus);
}
/**
* @param array<string, array{normalized_status: string, description: string}> $overrides keyed by "provider:raw_status"
*/
public static function descriptionWithOverrides(string $provider, string $rawStatus, array $overrides): string
{
$key = $provider . ':' . $rawStatus;
if (isset($overrides[$key]) && $overrides[$key]['description'] !== '') {
return $overrides[$key]['description'];
}
return self::description($provider, $rawStatus);
}
public static function normalize(string $provider, string $rawStatus): string
{
$map = match ($provider) {

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Modules\Shipments;
use PDO;
final class DeliveryStatusMappingRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return array<int, array{provider: string, raw_status: string, normalized_status: string, description: string}>
*/
public function listByProvider(string $provider): array
{
$provider = strtolower(trim($provider));
if ($provider === '') {
return [];
}
$statement = $this->pdo->prepare(
'SELECT provider, raw_status, normalized_status, description
FROM delivery_status_mappings
WHERE provider = :provider
ORDER BY raw_status ASC'
);
$statement->execute(['provider' => $provider]);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
return [];
}
return array_map(static function (array $row): array {
return [
'provider' => (string) ($row['provider'] ?? ''),
'raw_status' => (string) ($row['raw_status'] ?? ''),
'normalized_status' => (string) ($row['normalized_status'] ?? ''),
'description' => (string) ($row['description'] ?? ''),
];
}, $rows);
}
public function upsertMapping(string $provider, string $rawStatus, string $normalizedStatus, string $description): void
{
$provider = strtolower(trim($provider));
$rawStatus = trim($rawStatus);
if ($provider === '' || $rawStatus === '') {
return;
}
$statement = $this->pdo->prepare(
'INSERT INTO delivery_status_mappings (provider, raw_status, normalized_status, description, created_at, updated_at)
VALUES (:provider, :raw_status, :normalized_status, :description, NOW(), NOW())
ON DUPLICATE KEY UPDATE
normalized_status = VALUES(normalized_status),
description = VALUES(description),
updated_at = NOW()'
);
$statement->execute([
'provider' => $provider,
'raw_status' => $rawStatus,
'normalized_status' => $normalizedStatus,
'description' => $description,
]);
}
public function deleteMapping(string $provider, string $rawStatus): void
{
$provider = strtolower(trim($provider));
$rawStatus = trim($rawStatus);
if ($provider === '' || $rawStatus === '') {
return;
}
$statement = $this->pdo->prepare(
'DELETE FROM delivery_status_mappings WHERE provider = :provider AND raw_status = :raw_status'
);
$statement->execute([
'provider' => $provider,
'raw_status' => $rawStatus,
]);
}
public function deleteAllByProvider(string $provider): void
{
$provider = strtolower(trim($provider));
if ($provider === '') {
return;
}
$statement = $this->pdo->prepare(
'DELETE FROM delivery_status_mappings WHERE provider = :provider'
);
$statement->execute(['provider' => $provider]);
}
/**
* @return array<string, array{normalized_status: string, description: string}>
*/
public function getAllOverrides(): array
{
$statement = $this->pdo->query(
'SELECT provider, raw_status, normalized_status, description FROM delivery_status_mappings'
);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
$key = ((string) ($row['provider'] ?? '')) . ':' . ((string) ($row['raw_status'] ?? ''));
$result[$key] = [
'normalized_status' => (string) ($row['normalized_status'] ?? ''),
'description' => (string) ($row['description'] ?? ''),
];
}
return $result;
}
}