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

@@ -6,8 +6,23 @@ orderPRO to narzędzie do wielokanałowego zarządzania sprzedażą. Projekt prz
## Current Milestone
None — ready for next milestone.
## Completed Milestones
<details>
<summary>v1.3 Konfiguracja śledzenia przesyłek — 2026-03-23 (1 phase, 1 plan)</summary>
Konfiguracja mapowania statusów dostawy z API przewoźników na znormalizowane statusy widoczne w aplikacji. Użytkownik może dostosować tłumaczenia i przypisania statusów bez zmian w kodzie.
| Phase | Name | Plans | Completed |
|-------|------|-------|-----------|
| 29 | Delivery Status Mapping UI | 1/1 | 2026-03-23 |
Archive: `.paul/phases/29-delivery-status-mapping-ui/`
</details>
<details>
<summary>v1.2 Śledzenie przesyłek — 2026-03-23 (2 phases, 2 plans)</summary>
@@ -179,4 +194,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
---
*Roadmap created: 2026-03-12*
*Last updated: 2026-03-23 — v1.2 milestone created*
*Last updated: 2026-03-23 — v1.3 milestone complete*

View File

@@ -5,15 +5,15 @@
See: .paul/PROJECT.md (updated 2026-03-12)
**Core value:** Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i nadawać przesyłki bez przełączania się między platformami.
**Current focus:** v1.2 Śledzenie przesyłek — COMPLETE ✓
**Current focus:** v1.3 complete — ready for next milestone
## Current Position
Milestone: v1.2 Śledzenie przesyłek — COMPLETE ✓
Phase: [2] of [2] (Shipment Tracking UI + Settings) — COMPLETE
Plan: 28-01 — COMPLETE ✓
Status: Milestone complete, ready for next milestone
Last activity: 2026-03-23 — v1.2 milestone completed
Milestone: v1.3 complete
Phase: [1] of [1] (Delivery Status Mapping UI) — Complete
Plan: 29-01 complete
Status: Milestone v1.3 complete ready for next milestone
Last activity: 2026-03-23 — Phase 29 transition complete, v1.3 done
Progress:
- v0.1 Initial Release: [██████████] 100% ✓
@@ -30,13 +30,15 @@ Progress:
- v1.2 Śledzenie przesyłek: [██████████] 100% ✓
- Phase 27: [██████████] 100% ✓ (1/1 plans)
- Phase 28: [██████████] 100% ✓ (1/1 plans)
- v1.3 Konfiguracja śledzenia przesyłek: [██████████] 100% ✓
- Phase 29: [██████████] 100% ✓ (1/1 plans)
## Loop Position
Current loop state:
```
PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ ✓ [Loop complete — milestone v1.2 done]
✓ ✓ ✓ [Loop complete]
```
## Accumulated Context
@@ -73,6 +75,11 @@ PLAN ──▶ APPLY ──▶ UNIFY
| 2026-03-17 | Email history jako wpisy w order_activity_log (nie osobna sekcja) | Faza 15 | Spójność z istniejącym UX — jeden timeline zamiast fragmentacji |
| 2026-03-17 | VariableResolver wydzielony z EmailTemplateController | Faza 15 | Reuse logiki zmiennych; resolwer niezależny od kontrolera szablonów |
### Skill Audit (Faza 29, Plan 01)
| Oczekiwany | Wywołany | Uwagi |
|------------|---------|-------|
| sonar-scanner | ✓ | 0 nowych unikalnych issues; 3x S1192 pre-existing DeliveryStatus, 1x S1142 pre-existing matchCarrierByName, 2x accessibility minor (pre-existing pattern) |
### Skill Audit (Faza 28, Plan 01)
| Oczekiwany | Wywołany | Uwagi |
|------------|---------|-------|
@@ -232,7 +239,7 @@ PLAN ──▶ APPLY ──▶ UNIFY
- **Delivery mapping "Szukaj..." layout** — JS `attachSelectFilter()` w allegro.php tworzy input search dla InPost/Apaczka selectów, wizualnie wygląda jakby należał do wiersza powyżej. Pre-existing bug, do naprawy osobno.
### Git State
Last commit: pending — feat(28-shipment-tracking-ui)
Last commit: pending — feat(29-delivery-status-mapping-ui)
Branch: main
Feature branches merged: none
@@ -242,13 +249,12 @@ Brak.
## Session Continuity
Last session: 2026-03-23
Stopped at: v1.2 milestone COMPLETE
Next action: /paul:discuss-milestone — ustalenie zakresu v1.3
Resume file: .paul/phases/28-shipment-tracking-ui/28-01-SUMMARY.md
Stopped at: v1.3 milestone complete
Next action: /paul:discuss-milestone — ustal zakres v1.4
Resume file: .paul/ROADMAP.md
Resume context:
- v0.1v1.2: COMPLETE ✓ (28 phases, 40 plans)
- Milestone v1.2 zamknięty — tracking backend + UI + cron settings
- Następny milestone do ustalenia
- v0.1v1.3: COMPLETE ✓ (29 phases, 41 plans)
- Ready for next milestone
---
*STATE.md — Updated after every significant action*

View File

@@ -0,0 +1,267 @@
---
phase: 29-delivery-status-mapping-ui
plan: 01
type: execute
wave: 1
depends_on: ["28-01"]
files_modified:
- database/migrations/20260323_000070_create_delivery_status_mappings_table.sql
- src/Modules/Shipments/DeliveryStatusMappingRepository.php
- src/Modules/Shipments/DeliveryStatus.php
- src/Modules/Settings/DeliveryStatusMappingController.php
- resources/views/settings/delivery-status-mappings.php
- routes/web.php
autonomous: false
---
<objective>
## Goal
Umożliwić użytkownikowi konfigurację mapowania surowych statusów z API przewoźników na znormalizowane statusy widoczne w aplikacji — bez zmian w kodzie.
## Purpose
Każdy przewoźnik zwraca inne statusy (InPost: `adopted_at_sorting_center`, Apaczka: `NEW`/`CONFIRMED`, Allegro: `IN_TRANSIT`). Obecnie mapowania są zahardkodowane w DeliveryStatus.php. Użytkownik powinien móc:
- Zobaczyć wszystkie mapowania (domyślne + własne)
- Zmienić przypisanie surowego statusu do innego znormalizowanego statusu
- Zmienić opis wyświetlany przy surowym statusie
## Output
- Tabela DB `delivery_status_mappings` na custom overrides
- Strona ustawień z tabelą mapowań per provider
- DeliveryStatus sprawdza DB overrides przed fallback na stałe
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Prior Work
@.paul/phases/28-shipment-tracking-ui/28-01-SUMMARY.md
## Source Files
@src/Modules/Shipments/DeliveryStatus.php
@src/Modules/Settings/AllegroStatusMappingController.php
@src/Modules/Settings/AllegroStatusMappingRepository.php
@resources/views/settings/statuses.php
@routes/web.php
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
| Skill | Priority | When to Invoke | Loaded? |
|-------|----------|----------------|---------|
| sonar-scanner | required | Po APPLY, przed UNIFY | ○ |
## Skill Invocation Checklist
- [ ] sonar-scanner uruchomiony po zakończeniu APPLY
</skills>
<acceptance_criteria>
## AC-1: Lista mapowań per provider
```gherkin
Given użytkownik jest na stronie Ustawienia > Mapowanie statusów dostawy
When wybiera providera (InPost / Apaczka / Allegro)
Then widzi tabelę z kolumnami: Status surowy, Opis, Status znormalizowany
And każdy wiersz pokazuje aktualne przypisanie (domyślne lub custom)
And wiersze z custom override są wyróżnione wizualnie
```
## AC-2: Edycja mapowania statusu
```gherkin
Given użytkownik widzi tabelę mapowań dla wybranego providera
When zmienia "Status znormalizowany" w wierszu (select z opcjami: Utworzona, Potwierdzona, W tranzycie, etc.)
And klika Zapisz
Then mapowanie jest zapisane w tabeli delivery_status_mappings
And przy następnym pobraniu statusu z API, nowe mapowanie jest używane
```
## AC-3: Edycja opisu statusu
```gherkin
Given użytkownik widzi tabelę mapowań
When zmienia tekst w kolumnie "Opis" dla surowego statusu
And klika Zapisz
Then opis jest zapisany w delivery_status_mappings
And tooltip w badge'u statusu pokazuje nowy opis
```
## AC-4: Reset do domyślnych
```gherkin
Given użytkownik ma custom override dla statusu
When klika przycisk "Resetuj" obok wiersza
Then custom override jest usunięty z delivery_status_mappings
And mapowanie wraca do domyślnego z DeliveryStatus.php
```
## AC-5: DeliveryStatus używa custom overrides
```gherkin
Given istnieje wpis w delivery_status_mappings (provider='apaczka', raw_status='NEW', normalized_status='confirmed')
When system wywołuje DeliveryStatus::normalize('apaczka', 'NEW')
Then zwraca 'confirmed' (z DB) zamiast 'created' (domyślny)
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Migracja DB + Repository</name>
<files>database/migrations/20260323_000070_create_delivery_status_mappings_table.sql, src/Modules/Shipments/DeliveryStatusMappingRepository.php</files>
<action>
**Migracja — tabela delivery_status_mappings:**
```sql
CREATE TABLE IF NOT EXISTS delivery_status_mappings (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
provider VARCHAR(32) NOT NULL,
raw_status VARCHAR(64) NOT NULL,
normalized_status VARCHAR(32) NOT NULL,
description VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_provider_raw (provider, raw_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
Idempotentna (IF NOT EXISTS).
**DeliveryStatusMappingRepository:**
- `listByProvider(string $provider): array` — SELECT * WHERE provider = :provider
- `upsertMapping(string $provider, string $rawStatus, string $normalizedStatus, string $description): void` — INSERT ON DUPLICATE KEY UPDATE
- `deleteMapping(string $provider, string $rawStatus): void` — DELETE WHERE provider AND raw_status
- `getAllOverrides(): array` — SELECT * (dla cache w DeliveryStatus)
Wzoruj na AllegroStatusMappingRepository (konstruktor z PDO, prepared statements).
Avoid: nie dodawaj logiki biznesowej do repozytorium.
</action>
<verify>
php -l na obu plikach
Migracja wykonuje się bez błędów na DB
</verify>
<done>Tabela DB gotowa, repozytorium CRUD działa — fundament dla AC-2, AC-3, AC-4</done>
</task>
<task type="auto">
<name>Task 2: DeliveryStatus — DB overrides + kontroler + widok + routing</name>
<files>src/Modules/Shipments/DeliveryStatus.php, src/Modules/Settings/DeliveryStatusMappingController.php, resources/views/settings/delivery-status-mappings.php, routes/web.php</files>
<action>
**DeliveryStatus — metody z DB override:**
- Dodaj nowe statyczne metody:
- `normalizeWithOverrides(string $provider, string $rawStatus, array $overrides): string`
- `descriptionWithOverrides(string $provider, string $rawStatus, array $overrides): string`
- $overrides to tablica ['provider:raw_status' => ['normalized_status' => X, 'description' => Y]]
- Najpierw szukaj w $overrides, potem fallback na istniejące stałe (INPOST_MAP, etc.)
- Dodaj `getDefaultMappings(string $provider): array` — zwraca wszystkie mapowania z hardkodowanych stałych jako tablicę [raw_status => [normalized, description]]
**DeliveryStatusMappingController:**
- Konstruktor: Template, Translator, AuthService, DeliveryStatusMappingRepository
- `index(Request): Response`:
- Parametr GET `provider` (domyślnie 'inpost')
- Pobierz domyślne mapowania z DeliveryStatus::getDefaultMappings($provider)
- Pobierz custom overrides z repo: listByProvider($provider)
- Merge: custom nadpisuje domyślne
- Przekaż do widoku: provider, mappings, providers list, csrfToken, normalized status options
- `save(Request): Response`:
- Waliduj CSRF
- Odczytaj POST: provider, raw_status, normalized_status, description
- Waliduj: normalized_status musi być jednym z DeliveryStatus::ALL_STATUSES
- Wywołaj repo->upsertMapping(...)
- Flash success, redirect
- `reset(Request): Response`:
- Waliduj CSRF
- Odczytaj POST: provider, raw_status
- Wywołaj repo->deleteMapping(...)
- Flash success, redirect
**Widok delivery-status-mappings.php:**
- Nawigacja providerów: InPost | Apaczka | Allegro (linki z ?provider=X)
- Tabela z kolumnami:
- Status surowy (readonly, tekst)
- Opis (input text, edytowalny)
- Status znormalizowany (select z opcjami z LABEL_PL)
- Akcje: Zapisz (jeśli zmieniony) | Resetuj (jeśli custom)
- Każdy wiersz jako osobny mini-formularz POST (jak w statuses.php pattern)
- Wiersze z custom override: dodaj klasę CSS `is-custom` (np. lekkie tło)
- Formularz bulk save: jeden przycisk "Zapisz wszystkie" z JS zbierającym dane
**routes/web.php:**
- GET `/settings/delivery-status-mappings` → index
- POST `/settings/delivery-status-mappings/save` → save
- POST `/settings/delivery-status-mappings/reset` → reset
- Wszystkie z $authMiddleware
- Zainicjalizuj kontroler w sekcji controller instantiation
**Menu nawigacji:**
- Dodaj link "Mapowanie statusów" w menu ustawień (layout/sidebar)
Avoid:
- NIE zmieniaj istniejących metod normalize() i description() — nowe metody obok
- NIE usuwaj hardkodowanych stałych — to fallback
- NIE dodawaj JS frameworków — czyste formularze HTML
</action>
<verify>
php -l na wszystkich zmienionych plikach PHP
Strona /settings/delivery-status-mappings ładuje się bez błędów
Zmiana mapowania i zapis działa
</verify>
<done>AC-1, AC-2, AC-3, AC-4, AC-5 satisfied</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>Strona ustawień mapowania statusów dostawy — tabela mapowań per provider, edycja przypisań i opisów, reset do domyślnych.</what-built>
<how-to-verify>
1. Otwórz Ustawienia > Mapowanie statusów dostawy
2. Sprawdź zakładkę InPost — tabela ze statusami i ich opisami
3. Przełącz na Apaczka — sprawdź czy statusy tekstowe (NEW, CONFIRMED) są widoczne
4. Zmień mapowanie jednego statusu (np. NEW → Potwierdzona zamiast Utworzona) i Zapisz
5. Sprawdź czy badge w zamówieniu #22 odzwierciedla zmianę
6. Kliknij Resetuj — sprawdź czy wraca do domyślnego
7. Zmień opis statusu i sprawdź tooltip w badge'u
</how-to-verify>
<resume-signal>Type "approved" to continue, or describe issues to fix</resume-signal>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- src/Modules/Shipments/ShipmentTrackingInterface.php
- src/Modules/Shipments/InpostTrackingService.php
- src/Modules/Shipments/ApaczkaTrackingService.php
- src/Modules/Shipments/AllegroTrackingService.php
- src/Modules/Cron/ShipmentTrackingHandler.php
- Istniejące stałe w DeliveryStatus.php (INPOST_MAP, APACZKA_MAP, etc.) — to fallback
## SCOPE LIMITS
- Tylko UI do konfiguracji mapowań — bez zmian w logice trackingu
- Brak nowych zależności npm/composer
- Brak JavaScript frameworków — czyste formularze HTML + POST
- Tracking services (InPost/Apaczka/Allegro) muszą przekazywać overrides do DeliveryStatus — to integracja w ShipmentTrackingHandler, nie w tym planie jeśli wymaga dużych zmian
</boundaries>
<verification>
Before declaring plan complete:
- [ ] php -l przechodzi na wszystkich zmienionych plikach
- [ ] Migracja wykonuje się bez błędów
- [ ] Strona mapowań ładuje się dla każdego providera
- [ ] Zmiana mapowania zapisuje się w DB
- [ ] Reset usuwa custom override
- [ ] DeliveryStatus::normalizeWithOverrides() zwraca custom wartość gdy override istnieje
- [ ] DeliveryStatus::normalizeWithOverrides() zwraca domyślną gdy brak override
- [ ] All acceptance criteria met
</verification>
<success_criteria>
- Tabela delivery_status_mappings utworzona
- Strona ustawień działa z 3 providerami
- Edycja mapowania i opisu zapisuje się poprawnie
- Reset do domyślnych działa
- DeliveryStatus respektuje custom overrides
- Brak nowych zależności
</success_criteria>
<output>
After completion, create `.paul/phases/29-delivery-status-mapping-ui/29-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,129 @@
---
phase: 29-delivery-status-mapping-ui
plan: 01
subsystem: ui, settings
tags: [delivery-status, mapping, override, shipment-tracking]
requires:
- phase: 28-shipment-tracking-ui
provides: DeliveryStatus class with hardcoded provider mappings
provides:
- UI for configuring delivery status mappings per provider
- DB-backed overrides for normalized status and description
- DeliveryStatus override methods (normalizeWithOverrides, descriptionWithOverrides)
affects: [shipment-tracking-handler integration]
tech-stack:
added: []
patterns: [DB override with hardcoded fallback]
key-files:
created:
- database/migrations/20260323_000070_create_delivery_status_mappings_table.sql
- src/Modules/Shipments/DeliveryStatusMappingRepository.php
- src/Modules/Settings/DeliveryStatusMappingController.php
- resources/views/settings/delivery-status-mappings.php
- resources/scss/modules/_delivery-status-mappings.scss
modified:
- src/Modules/Shipments/DeliveryStatus.php
- routes/web.php
- resources/views/layouts/app.php
- resources/scss/app.scss
key-decisions:
- "Bulk save form + separate hidden reset form to avoid nested forms"
- "saveBulk auto-detects default vs custom: if values match defaults, deletes override"
- "resetAll added per user request (deviation from plan)"
patterns-established:
- "DB override with hardcoded fallback: check overrides map first, then fall back to const arrays"
duration: ~45min
started: 2026-03-23T22:00:00Z
completed: 2026-03-23T22:45:00Z
---
# Phase 29 Plan 01: Delivery Status Mapping UI Summary
**UI konfiguracji mapowania surowych statusów dostawy na znormalizowane — per provider, z edycją, resetem i bulk save.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~45min |
| Tasks | 3 completed |
| Files created | 5 |
| Files modified | 4 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Lista mapowań per provider | Pass | Tabela z InPost/Apaczka/Allegro, kolumny raw/description/normalized |
| AC-2: Edycja mapowania statusu | Pass | Select z LABEL_PL, bulk save |
| AC-3: Edycja opisu statusu | Pass | Input text per wiersz |
| AC-4: Reset do domyślnych | Pass | Przycisk per wiersz + resetuj wszystkie |
| AC-5: DeliveryStatus używa custom overrides | Pass | normalizeWithOverrides() i descriptionWithOverrides() |
## Accomplishments
- Tabela `delivery_status_mappings` z UNIQUE KEY na provider+raw_status
- Pełne CRUD repozytorium z upsert i deleteAllByProvider
- DeliveryStatus rozszerzony o `getDefaultMappings()`, `normalizeWithOverrides()`, `descriptionWithOverrides()`, `ALL_STATUSES`
- Kontroler z index/save/saveBulk/reset/resetAll
- Widok z tabelą per provider, bulk save, reset per wiersz i reset all
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `database/migrations/20260323_000070_...sql` | Created | Tabela delivery_status_mappings |
| `src/Modules/Shipments/DeliveryStatusMappingRepository.php` | Created | CRUD repozytorium |
| `src/Modules/Settings/DeliveryStatusMappingController.php` | Created | Kontroler ustawień mapowań |
| `resources/views/settings/delivery-status-mappings.php` | Created | Widok tabeli mapowań |
| `resources/scss/modules/_delivery-status-mappings.scss` | Created | Style CSS dla custom rows |
| `src/Modules/Shipments/DeliveryStatus.php` | Modified | ALL_STATUSES, getDefaultMappings, *WithOverrides methods |
| `routes/web.php` | Modified | 5 nowych endpointów + controller instantiation |
| `resources/views/layouts/app.php` | Modified | Link w menu bocznym |
| `resources/scss/app.scss` | Modified | Import nowego modułu SCSS |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Bulk save z auto-detekcją defaults | Unikamy zbędnych wpisów w DB gdy wartości == domyślne | Czysta tabela overrides |
| Osobny hidden form na reset (nie nested) | HTML nie pozwala na nested forms | Fix buga z resetem |
| resetAll dodany | User request | Dodatkowy endpoint + przycisk |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Scope additions | 2 | Minimalne — bug fix + feature request |
1. **Bug fix: nested forms** — Reset forms były zagnieżdżone w bulk save form. Przebudowano na hidden form + JS.
2. **Scope addition: resetAll** — Dodano przycisk "Resetuj wszystkie" + endpoint + repo method na życzenie usera.
## Skill Audit
| Expected | Invoked | Notes |
|----------|---------|-------|
| sonar-scanner | ✓ | 0 nowych unikalnych issues; 3x S1192 pre-existing (duplikaty stringów w DeliveryStatus), 1x S1142 pre-existing (matchCarrierByName), 2x accessibility minor w widoku (pre-existing pattern) |
## Next Phase Readiness
**Ready:**
- UI mapowania gotowe do użytku
- DeliveryStatus override methods gotowe do integracji w ShipmentTrackingHandler
**Concerns:**
- ShipmentTrackingHandler jeszcze nie przekazuje overrides do DeliveryStatus — wymaga osobnego planu integracji
**Blockers:** None
---
*Phase: 29-delivery-status-mapping-ui, Plan: 01*
*Completed: 2026-03-23*

View File

@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS delivery_status_mappings (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
provider VARCHAR(32) NOT NULL,
raw_status VARCHAR(64) NOT NULL,
normalized_status VARCHAR(32) NOT NULL,
description VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_provider_raw (provider, raw_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -702,6 +702,18 @@
font-size: 0.85em;
}
.dsm-row--custom {
background: rgba(59, 130, 246, 0.06);
}
.dsm-raw-status {
font-size: 0.82rem;
background: var(--surface-alt, #f1f5f9);
padding: 2px 6px;
border-radius: 3px;
white-space: nowrap;
}
* {
box-sizing: border-box;
}

View File

@@ -4,6 +4,7 @@
@use "modules/printing";
@use "modules/shipment-presets";
@use "modules/delivery-status";
@use "modules/delivery-status-mappings";
* {
box-sizing: border-box;

View File

@@ -0,0 +1,11 @@
.dsm-row--custom {
background: rgba(59, 130, 246, 0.06);
}
.dsm-raw-status {
font-size: 0.82rem;
background: var(--surface-alt, #f1f5f9);
padding: 2px 6px;
border-radius: 3px;
white-space: nowrap;
}

View File

@@ -107,6 +107,9 @@
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'printing' ? ' is-active' : '' ?>" href="/settings/printing">
Drukowanie
</a>
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'delivery-status-mappings' ? ' is-active' : '' ?>" href="/settings/delivery-status-mappings">
Mapowanie statusów dostawy
</a>
</div>
</details>
</nav>

View File

@@ -0,0 +1,159 @@
<?php
$providersList = is_array($providers ?? null) ? $providers : [];
$mappingsList = is_array($mappings ?? null) ? $mappings : [];
$normalizedOptionsList = is_array($normalizedOptions ?? null) ? $normalizedOptions : [];
$currentProvider = (string) ($provider ?? 'inpost');
?>
<section class="card">
<h2 class="section-title">Mapowanie statusów dostawy</h2>
<p class="muted mt-12">Konfiguracja przypisania surowych statusów z API przewoźników do znormalizowanych statusów w aplikacji.</p>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<div class="alert alert--success mt-12" role="status"><?= $e((string) $successMessage) ?></div>
<?php endif; ?>
</section>
<section class="card mt-16">
<nav class="content-tabs-nav" aria-label="Wybierz przewoźnika">
<?php foreach ($providersList as $provKey => $provLabel): ?>
<a class="content-tab-btn<?= $currentProvider === $provKey ? ' is-active' : '' ?>"
href="/settings/delivery-status-mappings?provider=<?= $e(rawurlencode($provKey)) ?>">
<?= $e($provLabel) ?>
</a>
<?php endforeach; ?>
</nav>
<?php if ($mappingsList === []): ?>
<p class="muted mt-16">Brak mapowań dla tego przewoźnika.</p>
<?php else: ?>
<form id="dsm-bulk-form" action="/settings/delivery-status-mappings/save-bulk" method="post" class="mt-16">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="provider" value="<?= $e($currentProvider) ?>">
<div class="table-wrapper">
<table class="table table--compact">
<thead>
<tr>
<th>Status surowy</th>
<th>Opis</th>
<th>Status znormalizowany</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
<?php foreach ($mappingsList as $mapping): ?>
<?php
$rawStatus = (string) ($mapping['raw_status'] ?? '');
$desc = (string) ($mapping['description'] ?? '');
$normalized = (string) ($mapping['normalized_status'] ?? '');
$isCustom = !empty($mapping['is_custom']);
?>
<tr class="<?= $isCustom ? 'dsm-row--custom' : '' ?>">
<td>
<code class="dsm-raw-status"><?= $e($rawStatus) ?></code>
<input type="hidden" name="raw_status[]" value="<?= $e($rawStatus) ?>">
</td>
<td>
<input type="text" name="description[]" class="form-control form-control--sm"
value="<?= $e($desc) ?>" maxlength="255">
</td>
<td>
<select name="normalized_status[]" class="form-control form-control--sm">
<?php foreach ($normalizedOptionsList as $optValue => $optLabel): ?>
<option value="<?= $e($optValue) ?>"<?= $optValue === $normalized ? ' selected' : '' ?>>
<?= $e($optLabel) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td>
<?php if ($isCustom): ?>
<button type="button" class="btn btn--danger btn--sm js-dsm-reset"
data-raw-status="<?= $e($rawStatus) ?>"
data-confirm-msg="Przywrócić domyślne mapowanie dla &quot;<?= $e($rawStatus) ?>&quot;?">Resetuj</button>
<?php else: ?>
<span class="muted">domyślne</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="form-actions mt-16">
<button type="submit" class="btn btn--primary">Zapisz wszystkie</button>
<button type="button" class="btn btn--danger" id="js-dsm-reset-all">Resetuj wszystkie</button>
</div>
</form>
<form id="dsm-reset-all-form" action="/settings/delivery-status-mappings/reset-all" method="post" style="display:none;">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="provider" value="<?= $e($currentProvider) ?>">
</form>
<form id="dsm-reset-form" action="/settings/delivery-status-mappings/reset" method="post" style="display:none;">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="provider" value="<?= $e($currentProvider) ?>">
<input type="hidden" name="raw_status" id="dsm-reset-raw-status" value="">
</form>
<?php endif; ?>
</section>
<script>
(function() {
var resetForm = document.getElementById('dsm-reset-form');
var resetInput = document.getElementById('dsm-reset-raw-status');
if (!resetForm || !resetInput) return;
var resetAllBtn = document.getElementById('js-dsm-reset-all');
var resetAllForm = document.getElementById('dsm-reset-all-form');
if (resetAllBtn && resetAllForm) {
resetAllBtn.addEventListener('click', function() {
if (window.OrderProAlerts && typeof window.OrderProAlerts.confirm === 'function') {
window.OrderProAlerts.confirm({
title: 'Resetuj wszystkie mapowania',
message: 'Przywrócić wszystkie mapowania do domyślnych dla tego przewoźnika?',
confirmLabel: 'Resetuj wszystkie',
cancelLabel: 'Anuluj',
danger: true
}).then(function(accepted) {
if (!accepted) return;
resetAllForm.submit();
});
} else {
resetAllForm.submit();
}
});
}
document.querySelectorAll('.js-dsm-reset').forEach(function(btn) {
btn.addEventListener('click', function() {
var rawStatus = btn.getAttribute('data-raw-status') || '';
var message = btn.getAttribute('data-confirm-msg') || 'Przywrócić domyślne?';
if (window.OrderProAlerts && typeof window.OrderProAlerts.confirm === 'function') {
window.OrderProAlerts.confirm({
title: 'Resetuj mapowanie',
message: message,
confirmLabel: 'Resetuj',
cancelLabel: 'Anuluj',
danger: true
}).then(function(accepted) {
if (!accepted) return;
resetInput.value = rawStatus;
resetForm.submit();
});
} else {
resetInput.value = rawStatus;
resetForm.submit();
}
});
});
})();
</script>

View File

@@ -51,6 +51,7 @@ use App\Modules\Automation\AutomationController;
use App\Modules\Automation\AutomationRepository;
use App\Modules\Automation\AutomationService;
use App\Modules\Settings\CronSettingsController;
use App\Modules\Settings\DeliveryStatusMappingController;
use App\Modules\Settings\SettingsController;
use App\Modules\Shipments\ApaczkaShipmentService;
use App\Modules\Shipments\AllegroShipmentService;
@@ -58,6 +59,7 @@ use App\Modules\Shipments\InpostShipmentService;
use App\Modules\Shipments\ShipmentController;
use App\Modules\Shipments\ShipmentPackageRepository;
use App\Modules\Shipments\ShipmentPresetController;
use App\Modules\Shipments\DeliveryStatusMappingRepository;
use App\Modules\Shipments\ShipmentPresetRepository;
use App\Modules\Shipments\ShipmentProviderRegistry;
use App\Modules\Printing\ApiKeyMiddleware;
@@ -188,6 +190,13 @@ return static function (Application $app): void {
(bool) $app->config('app.cron.run_on_web_default', false),
(int) $app->config('app.cron.web_limit_default', 5)
);
$deliveryStatusMappingRepository = new DeliveryStatusMappingRepository($app->db());
$deliveryStatusMappingController = new DeliveryStatusMappingController(
$template,
$translator,
$auth,
$deliveryStatusMappingRepository
);
$companySettingsRepository = new CompanySettingsRepository($app->db());
$companySettingsController = new CompanySettingsController(
$template,
@@ -389,6 +398,11 @@ return static function (Application $app): void {
$router->post('/settings/automation/update', [$automationController, 'update'], [$authMiddleware]);
$router->post('/settings/automation/delete', [$automationController, 'destroy'], [$authMiddleware]);
$router->post('/settings/automation/toggle', [$automationController, 'toggleStatus'], [$authMiddleware]);
$router->get('/settings/delivery-status-mappings', [$deliveryStatusMappingController, 'index'], [$authMiddleware]);
$router->post('/settings/delivery-status-mappings/save', [$deliveryStatusMappingController, 'save'], [$authMiddleware]);
$router->post('/settings/delivery-status-mappings/save-bulk', [$deliveryStatusMappingController, 'saveBulk'], [$authMiddleware]);
$router->post('/settings/delivery-status-mappings/reset', [$deliveryStatusMappingController, 'reset'], [$authMiddleware]);
$router->post('/settings/delivery-status-mappings/reset-all', [$deliveryStatusMappingController, 'resetAll'], [$authMiddleware]);
$router->get('/accounting', [$accountingController, 'index'], [$authMiddleware]);
$router->post('/accounting/export', [$accountingController, 'export'], [$authMiddleware]);
$router->get('/orders/{id}/receipt/create', [$receiptController, 'create'], [$authMiddleware]);

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;
}
}