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:
222
src/Modules/Settings/DeliveryStatusMappingController.php
Normal file
222
src/Modules/Settings/DeliveryStatusMappingController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
124
src/Modules/Shipments/DeliveryStatusMappingRepository.php
Normal file
124
src/Modules/Shipments/DeliveryStatusMappingRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user