feat(127): erli integration foundation

Phase 127 complete:

- add global Erli settings schema and encrypted API key repository

- add real read-only Erli API connection test and settings UI

- expose Erli in integrations hub and update PAUL/docs state
This commit is contained in:
2026-05-15 23:26:44 +02:00
parent afdbc67887
commit d6b18a6438
18 changed files with 1281 additions and 28 deletions

View File

@@ -2,6 +2,12 @@
Completed milestone log for this project.
## Active / Planned
| Milestone | Status | Scope | Stats |
|-----------|--------|-------|-------|
| v3.8 Erli Marketplace Integration | Ready to plan | Erli API settings, order import, labels, status sync, tracking, automation hooks | 6 phases, plans TBD |
| Milestone | Completed | Duration | Stats |
|-----------|-----------|----------|-------|
| v0.1 Initial Release | 2026-03-13 | 2 days | 6 phases, 15 plans |

View File

@@ -12,9 +12,9 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
| Attribute | Value |
|-----------|-------|
| Version | 3.7.0-dev |
| Status | v3.7 in progress — Phases 113-125 shipped (Fakturownia + HostedSMS/SMSPLANET + Alert unify + receipt VAT + SMS templates + invoice_requested import fix) |
| Last Updated | 2026-05-13 (Phase 125 closed) |
| Version | 3.8.0-dev |
| Status | v3.8 Erli Marketplace Integration in progress — Phase 127 shipped (Erli settings/API foundation); Phase 128 next |
| Last Updated | 2026-05-15 (Phase 127 closed) |
## Requirements
@@ -126,6 +126,7 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
- [x] Eksport XLSX paragonow w `/accounting`: nowe naglowki (Numer | Data wystawienia | Kwota brutto | Kwota netto | Stawka VAT | Kwota VAT) z osobnym wierszem per stawka VAT; `items_json` snapshot rozszerzony o `vat` per pozycja (z `order_items.tax_rate`, fallback 23.0); legacy fallback `net = brutto/1.23` — Phase 123
- [x] Szablony SMS: CRUD w `/settings/sms-templates` (name + body + is_active), wspolny `SmsVariableResolver` wydzielony z Email\\VariableResolver (placeholdery `{{zamowienie.*|kupujacy.*|adres.*|firma.*|przesylka.*}}`), dropdown "Wybierz szablon" w zakladce SMS na `/orders/{id}` wstawia rozwiniete zmienne do textarea (z `OrderProAlerts.confirm` przy nadpisaniu); stopka SMSPLANET dalej doklejana wylacznie przez `SmsConversationService::buildFinalOutboundBody()` (Phase 122 contract preserved) — Phase 124
- [x] Bugfix detekcji faktury przy imporcie: shopPRO order z `firm_nip` ustawia `invoice_requested=1` (mapper jako jedyne zrodlo heurystyki, sync service propaguje `aggregate['invoice_detected']`); Allegro rozszerzony o `naturalPerson=false`/`address.taxId`/`companyName` (wczesniej tylko `invoice.required`); usunieta legacy kolumna `orders.is_invoice` (Phase 115 dryft) + backfill 7 zamowien — Phase 125
- [x] Fundament integracji Erli: pojedyncza globalna konfiguracja `/settings/integrations/erli`, szyfrowany Bearer API key, realny test `GET /svc/shop-api/inbox`, karta w hubie integracji oraz dokumentacja schematu/architektury — Phase 127
### Deferred
@@ -134,10 +135,14 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
### Active (In Progress)
- [ ] v3.7 Invoices / operational integrations — Phases 113 + 114 + 115 + 116 + 117 shipped; ewentualne kolejne fazy (np. eksport XLSX faktur, invoice.created event, idempotencja Fakturowni, automatyzacje SMS, odbior SMS po aktywacji HostedSMS) w kolejce.
- [ ] v3.8 Erli Marketplace Integration — Phase 127 shipped; Phase 128 next: pobieranie zamowien Erli przez cron/import reczny, mapper do wspolnego modelu orderPRO i state cursor.
### Planned (Next)
- [ ] Erli status mapping + sync — Phase 129
- [ ] Erli shipments + labels — Phase 130
- [ ] Erli tracking + automation hooks — Phase 131
- [ ] Erli hardening, observability + docs — Phase 132
- [ ] ZarzÄ…dzanie produktami
- [ ] ZarzÄ…dzanie stanami magazynowymi
- [ ] Mobile Orders List / Mobile Order Details / Mobile Settings — pelna wersja mobilna pozostalych ekranow
@@ -237,12 +242,14 @@ PHP (XAMPP/Laravel), integracje z API marketplace'Ăłw (Allegro, Erli) oraz API
| Flash dual API: `Flash::push(type, message)` (preferred, typed) + `Flash::set/get(key, value)` (BC) | Phase 120: layouty (app/auth/public) iterują `Flash::all()` automatycznie. Kontrolery mogą używać dowolnego API; legacy klucze są mapowane heurystyką (`.save/.created/.deleted/.toggled` → success, `error/fail/danger` → danger, `warning` → warning, reszta → info) | 2026-05-12 | Active |
| `OrderProAlerts.confirm` ZAWSZE options-object API + Alert component zawsze przez `include` | Phase 120 ustalil format komponentu z `extract` (locals `$type`, `$message`, `$dismissible`). Trusted HTML przez `$messageHtml` z `unset()` po użyciu (`isset` persiste w PHP `include` scope) | 2026-05-12 | Active |
| `$messageHtml` w alert component musi być `unset()` po każdym include | PHP `include` widzi zmienne kontekstu z extracted scope; bez `unset` kolejny include w tym samym widoku falszywie wykrywa `isset($messageHtml)`. Pattern dla wszystkich miejsc używających `$messageHtml` (4 widoki: invoice_form, receipt-create, printing, statistics/orders) | 2026-05-12 | Active |
| Erli startuje jako jedna globalna konfiguracja bez sandbox switcha | Operator wybral prosty model pojedynczego konta; srodowisko testowe Erli wymaga osobnej domeny z BOK, wiec nie trafia do Phase 127 | 2026-05-15 | Active |
| Test Erli uzywa realnego read-only `GET /inbox` | Operator wymagal realnego testu API, ale fundament nie moze jeszcze importowac zamowien ani oznaczac inboxa jako przeczytanego | 2026-05-15 | Active |
## Success Metrics
| Metric | Target | Current | Status |
|--------|--------|---------|--------|
| Liczba zintegrowanych źródeŠzamówień | ≥3 | 2 (Allegro, Erli) | In progress |
| Liczba zintegrowanych źródeŠzamówień | ≥3 | 2 aktywne importy + fundament Erli | In progress |
| Generowanie etykiet | DziaĹa | InPost | In progress |
## Tech Stack
@@ -268,6 +275,6 @@ Quick Reference:
---
*PROJECT.md — Updated when requirements or context change*
*Last updated: 2026-05-12 after Phase 120 (Alert Component Unification) closure; v3.7 milestone in progress*
*Last updated: 2026-05-15 after Phase 127 (Erli Integration Foundation) closure; v3.8 milestone in progress*

View File

@@ -6,7 +6,52 @@ orderPRO to narzedzie do wielokanalowego zarzadzania sprzedaza. Projekt przechod
## Current Milestone
v3.7 Invoices (Fakturownia integration) — In progress
v3.8 Erli Marketplace Integration — In progress
Pelna integracja z erli.pl wzorowana na istniejacej integracji Allegro: konfiguracja konta/API, pobieranie zamowien, mapowanie i synchronizacja statusow, generowanie etykiet, tracking oraz wlaczenie Erli w istniejace przeplywy automatyzacji, statystyk i obslugi zamowien.
| Phase | Name | Plans | Status |
|-------|------|-------|--------|
| 127 | Erli Integration Foundation | 1/1 | Complete (2026-05-15; migration/manual Erli API smoke pending operator) |
| 128 | Erli Orders Import | TBD | Not started |
| 129 | Erli Status Mapping + Sync | TBD | Not started |
| 130 | Erli Shipments + Labels | TBD | Not started |
| 131 | Erli Tracking + Automation Hooks | TBD | Not started |
| 132 | Erli Hardening, Observability + Docs | TBD | Not started |
### Phase 127: Erli Integration Foundation
Focus: Dodac podstawowy typ integracji Erli: migracje konfiguracji, szyfrowanie sekretow, klient API, test polaczenia, karta w hubie integracji i routing/settings zgodne z wzorcami Allegro/shopPRO.
Plans: 127-01 (complete)
### Phase 128: Erli Orders Import
Focus: Pobieranie nowych zamowien Erli przez cron i import reczny, mapper do wspolnego modelu orderPRO, state cursor, delta-only re-import, adresy/pozycje/platnosci/notatki oraz flaga faktury/NIP tam, gdzie API Erli daje dane firmowe.
Plans: TBD (defined during $paul-plan)
### Phase 129: Erli Status Mapping + Sync
Focus: Osobne mapowanie pull/push statusow Erli, auto-discovery nieznanych statusow, cron synchronizacji orderPRO -> Erli i ochrona lokalnych statusow przy re-imporcie analogicznie do Allegro/shopPRO.
Plans: TBD (defined during $paul-plan)
### Phase 130: Erli Shipments + Labels
Focus: Generowanie etykiet dla zamowien Erli, mapowanie metod dostawy Erli na dostepne providery, zapis paczek w `shipment_packages`, pobieranie labeli i integracja z kolejka zdalnego druku.
Plans: TBD (defined during $paul-plan)
### Phase 131: Erli Tracking + Automation Hooks
Focus: Tracking przesylek Erli, aktualizacja delivery statusow, zdarzenia automatyzacji (`order.imported`, `shipment.created`, `shipment.status_changed`) i zachowanie kompatybilnosci z szablonami e-mail/SMS oraz statystykami.
Plans: TBD (defined during $paul-plan)
### Phase 132: Erli Hardening, Observability + Docs
Focus: Testy jednostkowe mapperow/klientow, logi integracji i bledow API, retry/idempotencja, manual smoke checklist na zywej konfiguracji oraz aktualizacja `DOCS/DB_SCHEMA.md`, `DOCS/ARCHITECTURE.md` i `DOCS/TECH_CHANGELOG.md`.
Plans: TBD (defined during $paul-plan)
## Previous Milestone (transition pending)
v3.7 Invoices (Fakturownia integration) — Complete in code, transition/follow-ups pending
Wystawianie faktur dla klientow z NIP poprzez integracje z Fakturownia (app.fakturownia.pl). Numeracja lokalna z opcja delegacji do Fakturowni, rozdzielenie przyciskow "Wystaw paragon" / "Wystaw fakture", osobne podstrony edycji konfiguracji paragonow i faktur.
@@ -38,7 +83,7 @@ Planowane kolejne fazy v3.7 (kandydaci, do rozplanowania):
## Next Milestone
Kandydaci w kolejce (po v3.7):
Kandydaci w kolejce (po v3.8):
- Mobile Orders List / Mobile Order Details / Mobile Settings
- Zarzadzanie produktami
- Zarzadzanie stanami magazynowymi
@@ -508,4 +553,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
---
*Roadmap created: 2026-03-12*
*Last updated: 2026-05-13 - Phase 126 UNIFY closed*
*Last updated: 2026-05-15 - Phase 127 UNIFY closed*

View File

@@ -5,33 +5,33 @@
See: .paul/PROJECT.md (updated 2026-05-07)
**Core value:** Sprzedawca moze obslugiwac zamowienia ze wszystkich kanalow sprzedazy i nadawac przesylki bez przelaczania sie miedzy platformami.
**Current focus:** v3.7 Invoices + operational integrations - Phase 126 GUS field mapping fix complete (UNIFY closed).
**Current focus:** v3.8 Erli Marketplace Integration - Phase 127 complete; Phase 128 ready to plan.
## Current Position
Milestone: v3.7 Invoices (Fakturownia integration) - In progress
Phase: 126 of TBD (Invoice GUS field mapping fix) - Complete
Plan: 126-01 complete (SUMMARY.md created)
Status: UNIFY complete, transition pending (commit + ROADMAP update)
Last activity: 2026-05-13 - Phase 126-01 UNIFY zakonczony
Milestone: v3.8 Erli Marketplace Integration
Phase: 128 of 132 (Erli Orders Import)
Plan: Not started
Status: Ready to plan
Last activity: 2026-05-15 23:26 - Phase 127 complete; transitioned to Phase 128
Progress:
- Milestone v3.7: [##########] ~99% (Phase 113-126 complete; transition pending)
- Phase 126: [##########] 100%
- Milestone v3.8: [##--------] ~16% (Phase 127 complete)
- Phase 128: [----------] 0% (not planned)
## Loop Position
Current loop state:
```
PLAN -> APPLY -> UNIFY
done done done [Loop complete - transition pending]
done done done [Loop complete - ready for next PLAN]
```
## Session Continuity
Last session: 2026-05-13
Stopped at: Phase 126-01 UNIFY closed; SUMMARY.md created
Next action: Phase transition (commit + ROADMAP update), then manual smoke (AC-1) i wybor kolejnego kandydata v3.7
Last session: 2026-05-15 23:26
Stopped at: Phase 127 complete; Phase 128 ready to plan
Next action: $paul-plan for Phase 128 (Erli Orders Import)
Resume file: .paul/ROADMAP.md
## Pending parallel work
@@ -39,9 +39,9 @@ Resume file: .paul/ROADMAP.md
## Git State
Last phase commit: c758ec7 feat(126): invoice GUS field mapping fix (JDG/KRS heuristic)
Previous: 2ab461a feat(125): invoice_requested import fix + drop legacy is_invoice column
Branch: main (5 commits ahead of origin/main)
Last phase commit: pending feat(127): erli integration foundation
Previous: c758ec7 feat(126): invoice GUS field mapping fix (JDG/KRS heuristic)
Branch: main
## Pending Actions
@@ -65,6 +65,7 @@ Branch: main (5 commits ahead of origin/main)
- Phase 125 follow-up: zaimportuj nowe zamowienie shopPRO z `firm_nip` (bez kluczy w 5-elementowej liscie wczesniejszej heurystyki) -> potwierdz ze UI w zakladce Platnosci pokazuje zaznaczony checkbox „Klient prosi o fakture" i widoczny przycisk „Wystaw fakture".
- Phase 121 transition note (rozwiązane): commit 360eef1 obejmuje Phase 121 i Phase 122 razem; per-faza hunk-split nie wykonany ze względu na nakładkowe modyfikacje plików.
- Phase 126 follow-up: manual smoke `/orders/1090/invoice/create` (JDG, NIP 5170167517) -> "Imie i nazwisko"="JACEK PYZIAK", "Nazwa firmy"="Project-Pro Pyziak Jacek" niezmieniona; drugi smoke na zamowieniu spolki z aktywnym KRS; `curl /api/nip/lookup?nip=5170167517` -> `data.is_jdg=true`.
- Phase 127 follow-up: uruchom `php bin/migrate.php` gdy lokalny MySQL/XAMPP jest online, zapisz prawdziwy klucz Erli w `/settings/integrations/erli`, wykonaj realny test polaczenia i potwierdz wpis w hubie integracji.
## Deferred to Next Milestones

View File

@@ -0,0 +1,24 @@
# 2026-05-15
## Co zrobiono
- [Phase 127, Plan 01] Dodano fundament integracji Erli: globalna konfiguracja API, szyfrowany klucz, realny test polaczenia, widok ustawien i wiersz w hubie integracji.
- Utworzono plan i summary dla Phase 127 oraz przygotowano przejscie do Phase 128.
## Zmienione pliki
- `.paul/phases/127-erli-integration-foundation/127-01-PLAN.md`
- `.paul/phases/127-erli-integration-foundation/127-01-SUMMARY.md`
- `.paul/ROADMAP.md`
- `.paul/STATE.md`
- `database/migrations/20260515_000114_create_erli_integration_settings.sql`
- `src/Modules/Settings/ErliIntegrationRepository.php`
- `src/Modules/Settings/ErliApiClient.php`
- `src/Modules/Settings/ErliIntegrationController.php`
- `src/Modules/Settings/IntegrationsHubController.php`
- `routes/web.php`
- `resources/views/settings/erli.php`
- `resources/lang/pl.php`
- `DOCS/DB_SCHEMA.md`
- `DOCS/ARCHITECTURE.md`
- `DOCS/TECH_CHANGELOG.md`

View File

@@ -0,0 +1,259 @@
---
phase: 127-erli-integration-foundation
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- database/migrations/20260515_000114_create_erli_integration_settings.sql
- src/Modules/Settings/ErliIntegrationRepository.php
- src/Modules/Settings/ErliApiClient.php
- src/Modules/Settings/ErliIntegrationController.php
- src/Modules/Settings/IntegrationsHubController.php
- routes/web.php
- resources/views/settings/erli.php
- resources/lang/pl.php
- DOCS/DB_SCHEMA.md
- DOCS/ARCHITECTURE.md
- DOCS/TECH_CHANGELOG.md
autonomous: true
delegation: auto
---
<objective>
## Goal
Add the foundation for a single global Erli marketplace integration: database settings, encrypted API key storage, API client, settings UI, real connection test, hub row, routes, and technical documentation.
## Purpose
Erli is the next sales channel in orderPRO. This plan creates the same kind of safe integration footing that Allegro, Fakturownia, HostedSMS and SMSPLANET already use, without starting order import, label generation, status sync or tracking yet.
## Output
- New `erli_integration_settings` table linked to one `integrations.type='erli'` row.
- New Settings module classes for Erli configuration and API connection testing.
- New `/settings/integrations/erli` screen with save/test actions.
- Erli visible in `/settings/integrations`.
- Documentation updated for schema, architecture and technical changelog.
</objective>
<context>
<clarifications>
- **Instancje** - Czy integracja Erli ma byc jedna globalna konfiguracja czy obslugiwac wiele kont/sklepow Erli?
-> Odpowiedz: Jedna globalna.
- **Sandbox** - Czy w fazie fundamentu dodac przelacznik srodowiska production/sandbox dla Erli?
-> Odpowiedz: Nie.
- **Test API** - Jak ma dzialac przycisk "Test polaczenia" w Phase 127?
-> Odpowiedz: Realnie ma dzialac.
</clarifications>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
@.paul/codebase/architecture.md
@.paul/codebase/db_schema.md
## Required Project Docs
@DOCS/DB_SCHEMA.md
@DOCS/ARCHITECTURE.md
@DOCS/TECH_CHANGELOG.md
## Existing Patterns To Reuse
@src/Modules/Settings/FakturowniaIntegrationRepository.php
@src/Modules/Settings/FakturowniaIntegrationController.php
@src/Modules/Settings/FakturowniaApiClient.php
@src/Modules/Settings/SmsplanetIntegrationRepository.php
@src/Modules/Settings/SmsplanetIntegrationController.php
@src/Modules/Settings/IntegrationsHubController.php
@src/Modules/Settings/IntegrationsRepository.php
@resources/views/settings/fakturownia.php
@resources/views/settings/smsplanet.php
@database/migrations/20260512_000108_create_smsplanet_integration_settings.sql
@routes/web.php
@resources/lang/pl.php
## External API Reference
Erli API documentation: https://erli.pl/svc/shop-api/doc/
- REST API over HTTPS.
- Authorization uses Bearer API key in the `Authorization` header.
- Requests should include a meaningful `User-Agent`.
- Rate limiting can return HTTP 429.
- For this plan, use a safe read-only documented endpoint for connection testing, preferably the inbox endpoint (`/inbox`) with a small limit if supported by the docs.
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
| Skill | Priority | When to Invoke | Loaded? |
|-------|----------|----------------|---------|
| /feature-dev | optional | New marketplace integration work | ○ |
| /code-review | optional | After implementation, before UNIFY | ○ |
| /frontend-design | optional | New settings UI screen | ○ |
| sonar-scanner | required | After APPLY, before UNIFY | ○ |
**BLOCKING:** `sonar-scanner` is required after APPLY and before UNIFY when available in PATH.
</skills>
<acceptance_criteria>
## AC-1: Schema And Single Integration Row
```gherkin
Given migrations are run
When the Erli foundation migration executes
Then the database contains exactly one global `erli_integration_settings` row linked to one `integrations.type='erli'` row
And the settings table stores the API key only in encrypted form
And the migration is idempotent when run again
```
## AC-2: Save Erli Configuration
```gherkin
Given an authenticated operator opens `/settings/integrations/erli`
When they enter an API key, optional seller/account label, and active flag, then submit the form with a valid CSRF token
Then the configuration is saved
And returning to the screen shows that the secret is saved without revealing the raw API key
And invalid or missing required values produce a Polish error flash
```
## AC-3: Real API Connection Test
```gherkin
Given a complete and active Erli configuration is saved
When the operator clicks "Test polaczenia"
Then orderPRO sends a real authenticated read-only request to the Erli API
And stores `last_test_status`, `last_test_http_code`, `last_test_message`, and `last_test_at` in `integrations`
And the UI shows a readable success or failure result
```
## AC-4: Integrations Hub Visibility
```gherkin
Given the operator opens `/settings/integrations`
When the hub renders integration rows
Then Erli appears with configured/missing secret status, active status, last test timestamp, and a configure link to `/settings/integrations/erli`
```
## AC-5: Documentation Updated
```gherkin
Given Phase 127 changes are implemented
When documentation is reviewed
Then `DOCS/DB_SCHEMA.md`, `DOCS/ARCHITECTURE.md`, and `DOCS/TECH_CHANGELOG.md` describe the new Erli table, classes, routes, and decision boundaries
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Create Erli settings schema and repository</name>
<files>database/migrations/20260515_000114_create_erli_integration_settings.sql, src/Modules/Settings/ErliIntegrationRepository.php</files>
<action>
Create an idempotent migration for a single global Erli configuration:
- Insert or update base `integrations` row: `type='erli'`, `name='Erli'`, production API `base_url` from official docs, timeout around 15 seconds, active by default.
- Create `erli_integration_settings` with `id TINYINT UNSIGNED PRIMARY KEY` fixed at 1, nullable unique `integration_id` FK to `integrations(id)`, `api_key_encrypted TEXT NULL`, optional `account_label VARCHAR(128) NULL`, timestamps, and no sandbox/environment column.
- Insert row `id=1` linked to the Erli integration via `ON DUPLICATE KEY UPDATE`.
- Avoid `SELECT 1` no-op migrations; use real DDL/DML only, matching the project migration rule.
Create `ErliIntegrationRepository`:
- Mirror single-instance patterns from `FakturowniaIntegrationRepository` and `SmsplanetIntegrationRepository`.
- Use `IntegrationSecretCipher` for API key encryption/decryption.
- Expose `getSettings()`, `saveSettings(array $payload)`, `getCredentials(): ?array`, and private helpers for `ensureBaseIntegration()`, `ensureRow()`, validation and decryption.
- Preserve saved secret when the API key input is left empty on edit.
- Validate required API key on first save and active flag on the base integration.
- Use PDO prepared statements only.
</action>
<verify>`php -l src/Modules/Settings/ErliIntegrationRepository.php` and review migration SQL for idempotent `CREATE TABLE IF NOT EXISTS` + `ON DUPLICATE KEY UPDATE`</verify>
<done>AC-1 and AC-2 schema/repository portions satisfied</done>
</task>
<task type="auto">
<name>Task 2: Add Erli API client, controller, routes and settings view</name>
<files>src/Modules/Settings/ErliApiClient.php, src/Modules/Settings/ErliIntegrationController.php, routes/web.php, resources/views/settings/erli.php, resources/lang/pl.php</files>
<action>
Create `ErliApiClient`:
- Implement `testConnection(array $credentials): array{ok: bool, http_code: int, message: string}`.
- Send a real authenticated GET request to a read-only Erli endpoint from the official docs, preferably `/inbox` with a minimal limit when supported.
- Use `Authorization: Bearer {api_key}`, `Accept: application/json`, and `User-Agent: orderPRO/1.0 (+contact/orderpro)` style header.
- Use cURL with `SslCertificateResolver::resolve()`, timeouts, SSL verification, JSON/error parsing and no `curl_close()`.
- Treat 2xx as success, 401/403 as auth failure, 429 as rate-limit failure with readable Polish message, and other non-2xx as failure with a safe body snippet.
Create `ErliIntegrationController`:
- Actions: `index`, `save`, `test`.
- Use CSRF `_token`, `Flash`, `RedirectPathResolver`, and existing layout conventions.
- `test` must load saved credentials, call `ErliApiClient::testConnection()`, then write result via `IntegrationsRepository::updateTestResult()`.
Wire in `routes/web.php`:
- Instantiate repository/client/controller with existing `$app->db()` and integration secret.
- Register authenticated routes: `GET /settings/integrations/erli`, `POST /settings/integrations/erli/save`, `POST /settings/integrations/erli/test`.
Create `resources/views/settings/erli.php`:
- Compact settings page consistent with Fakturownia/SMSPLANET pages.
- Fields: account label (optional), API key password input with saved/missing hint, active checkbox.
- Test section with button/form and last test panel.
- Use reusable alert component includes; do not add inline CSS, native `alert()` or `confirm()`.
Add translation keys under settings for Erli provider labels, form fields, flash messages and hub provider name.
</action>
<verify>`php -l src/Modules/Settings/ErliApiClient.php`, `php -l src/Modules/Settings/ErliIntegrationController.php`, `php -l resources/views/settings/erli.php`, and `php -l routes/web.php`</verify>
<done>AC-2 and AC-3 satisfied: operator can save Erli config and run a real API test</done>
</task>
<task type="auto">
<name>Task 3: Add Erli to hub and update technical documentation</name>
<files>src/Modules/Settings/IntegrationsHubController.php, DOCS/DB_SCHEMA.md, DOCS/ARCHITECTURE.md, DOCS/TECH_CHANGELOG.md</files>
<action>
Update `IntegrationsHubController`:
- Add `ErliIntegrationRepository` constructor dependency.
- Add `buildErliRow()` mirroring Fakturownia/SMSPLANET single-instance hub rows.
- Include Erli in the rows array with configured/missing secret status, active status, last test timestamp, and configure URL `/settings/integrations/erli`.
- Keep constructor wiring in `routes/web.php` consistent with the new dependency from Task 2.
Update documentation:
- `DOCS/DB_SCHEMA.md`: add `erli_integration_settings` under Integrations with columns, FK, single-row constraint, and encrypted secret note.
- `DOCS/ARCHITECTURE.md`: add Erli foundation classes/routes to Settings and hub integration sections; explicitly state import/status/shipments are deferred to phases 128-131.
- `DOCS/TECH_CHANGELOG.md`: add a 2026-05-15 Phase 127 Plan 01 entry explaining what changed and why.
</action>
<verify>`php -l src/Modules/Settings/IntegrationsHubController.php` and text search confirms `Erli` appears in hub translations/docs</verify>
<done>AC-4 and AC-5 satisfied</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Do not modify Allegro, shopPRO, Fakturownia, HostedSMS or SMSPLANET behavior except for constructor wiring needed to add Erli to the hub.
- Do not change order import, `OrderImportRepository`, order list/detail behavior, shipment creation, tracking, status mapping, statistics aggregation, or automation execution in this plan.
- Do not introduce `DB_HOST_REMOTE` into runtime configuration.
- Do not store raw Erli API keys in plaintext, logs, flash messages, docs, or views.
## SCOPE LIMITS
- No order import from Erli in Phase 127; that starts in Phase 128.
- No Erli status mapping or status sync in Phase 127; that starts in Phase 129.
- No label generation or shipment integration in Phase 127; that starts in Phase 130.
- No tracking or automation hooks in Phase 127; that starts in Phase 131.
- No sandbox/environment switch per user clarification.
- No new frontend build requirement unless existing SCSS already requires it for changed shared styles; this plan should use existing compact form/card classes.
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `php -l` passes for all new/modified PHP files.
- [ ] Migration file is idempotent and uses FK to `integrations(id)`.
- [ ] Manual or local smoke: `/settings/integrations/erli` renders for an authenticated user.
- [ ] Manual or local smoke with real credentials: save config, run test, observe result in UI and `integrations.last_test_*`.
- [ ] `/settings/integrations` shows Erli row with configure link and status.
- [ ] `DOCS/DB_SCHEMA.md`, `DOCS/ARCHITECTURE.md`, and `DOCS/TECH_CHANGELOG.md` updated.
- [ ] `sonar-scanner` run after APPLY if available; if not available, document the gap in SUMMARY.
- [ ] All acceptance criteria met.
</verification>
<success_criteria>
- Erli has one global encrypted configuration in DB.
- Operator can save Erli API key without exposing it after save.
- Test connection performs a real authenticated read-only Erli API request and persists the result.
- Erli appears in the integrations hub.
- Documentation reflects the new schema and architecture.
- No unrelated marketplace/order/shipment behavior changed.
</success_criteria>
<output>
After completion, create `.paul/phases/127-erli-integration-foundation/127-01-SUMMARY.md`.
</output>

View File

@@ -0,0 +1,167 @@
---
phase: 127-erli-integration-foundation
plan: 01
subsystem: settings, integrations, api, database
tags: [erli, marketplace, integration-settings, api-client, encrypted-secrets]
requires:
- phase: 113-fakturownia-integration-foundation
provides: integrations hub test-result pattern and encrypted integration settings
- phase: 117-smsplanet-integration-settings
provides: single global settings + real API test pattern
provides:
- single global Erli API configuration
- encrypted Erli Bearer API key storage
- real Erli connection test via GET /inbox
- Erli row in integrations hub
affects: [erli-orders-import, erli-status-sync, erli-shipments, erli-tracking]
tech-stack:
added: []
patterns: [single-global-marketplace-settings, encrypted-bearer-api-key, real-readonly-api-test]
key-files:
created:
- database/migrations/20260515_000114_create_erli_integration_settings.sql
- src/Modules/Settings/ErliIntegrationRepository.php
- src/Modules/Settings/ErliApiClient.php
- src/Modules/Settings/ErliIntegrationController.php
- resources/views/settings/erli.php
modified:
- src/Modules/Settings/IntegrationsHubController.php
- routes/web.php
- resources/lang/pl.php
- DOCS/DB_SCHEMA.md
- DOCS/ARCHITECTURE.md
- DOCS/TECH_CHANGELOG.md
- .paul/ROADMAP.md
- .paul/STATE.md
key-decisions:
- "Erli starts as one global configuration, not multi-account"
- "No sandbox/environment switch in Phase 127"
- "Connection test performs a real read-only Erli API request"
patterns-established:
- "Erli settings mirror Fakturownia/SMSPLANET single-row repository pattern"
- "Erli API test uses Authorization: Bearer plus User-Agent and stores result in integrations.last_test_*"
duration: ~20min
started: 2026-05-15T23:00:00+02:00
completed: 2026-05-15T23:19:00+02:00
---
# Phase 127 Plan 01: Erli Integration Foundation Summary
**Single global Erli marketplace configuration with encrypted API key storage, real read-only API test, settings UI, and integrations hub row.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~20min |
| Started | 2026-05-15T23:00:00+02:00 |
| Completed | 2026-05-15T23:19:00+02:00 |
| Tasks | 3 completed |
| Files created | 6 |
| Files modified | 8 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Schema And Single Integration Row | Pass | Migration creates `erli_integration_settings`, seeds/updates `integrations.type='erli'`, fixed row `id=1`, encrypted key column. |
| AC-2: Save Erli Configuration | Pass | `/settings/integrations/erli/save` validates CSRF, saves label/API key/active flag, preserves secret on empty input, shows saved/missing state. |
| AC-3: Real API Connection Test | Pass | `ErliApiClient::testConnection()` performs authenticated `GET /inbox`; controller stores `integrations.last_test_*`. Live test awaits real credentials. |
| AC-4: Integrations Hub Visibility | Pass | `IntegrationsHubController::buildErliRow()` adds Erli with configured/missing, active, last test and configure link. |
| AC-5: Documentation Updated | Pass | `DOCS/DB_SCHEMA.md`, `DOCS/ARCHITECTURE.md`, `DOCS/TECH_CHANGELOG.md` updated. |
## Accomplishments
- Added global Erli settings table and repository with encrypted Bearer API key handling.
- Added real connection-test client for official Erli API using `GET https://erli.pl/svc/shop-api/inbox`.
- Added authenticated settings routes and compact UI under `/settings/integrations/erli`.
- Added Erli to the integrations hub.
- Documented schema, architecture and technical changelog.
## Task Commits
No per-task commits were created during APPLY. Phase commit is created during UNIFY transition.
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `database/migrations/20260515_000114_create_erli_integration_settings.sql` | Created | Global Erli config table and base integration seed. |
| `src/Modules/Settings/ErliIntegrationRepository.php` | Created | Settings persistence, secret encryption, active credentials. |
| `src/Modules/Settings/ErliApiClient.php` | Created | Real read-only Erli API connection test. |
| `src/Modules/Settings/ErliIntegrationController.php` | Created | Settings page, save action, test action. |
| `resources/views/settings/erli.php` | Created | Erli settings and test UI. |
| `routes/web.php` | Modified | Erli DI wiring and routes. |
| `resources/lang/pl.php` | Modified | Erli translations and hub provider label. |
| `src/Modules/Settings/IntegrationsHubController.php` | Modified | Erli row in integrations hub. |
| `DOCS/DB_SCHEMA.md` | Modified | Added `erli_integration_settings`. |
| `DOCS/ARCHITECTURE.md` | Modified | Added Erli foundation flow/classes. |
| `DOCS/TECH_CHANGELOG.md` | Modified | Added Phase 127 technical entry. |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Single global Erli configuration | User chose one global account; matches small single-instance integration pattern. | Future phases use one Erli integration id unless requirements change. |
| No sandbox switch | User explicitly declined sandbox/environment toggle. | UI/schema stay simpler; live testing uses production API credentials. |
| Real read-only connection test | User required a real API test. | Test uses `GET /inbox`, but does not import or mark messages read. |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Deferred | 2 | Environment/live resources needed |
| Scope additions | 0 | None |
| Auto-fixed | 0 | None |
### Deferred Items
- Run `php bin/migrate.php` when local MySQL/XAMPP is online.
- Save real Erli API key and perform manual `/settings/integrations/erli` connection test.
- `sonar-scanner` was not available in PATH, so scan was not run.
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| Live API verification needs real Erli credentials | Implemented real test path and documented manual follow-up. |
| SonarQube CLI missing from PATH | Documented as verification gap. |
## Verification Results
| Check | Result |
|-------|--------|
| `php -l src/Modules/Settings/ErliIntegrationRepository.php` | Pass |
| `php -l src/Modules/Settings/ErliApiClient.php` | Pass |
| `php -l src/Modules/Settings/ErliIntegrationController.php` | Pass |
| `php -l src/Modules/Settings/IntegrationsHubController.php` | Pass |
| `php -l resources/views/settings/erli.php` | Pass |
| `php -l routes/web.php` | Pass |
| `php -l resources/lang/pl.php` | Pass |
| `git diff --check` | Pass; only CRLF warnings from Git |
## Next Phase Readiness
**Ready:**
- Erli credentials can be stored and decrypted by future import/status/shipment services.
- Hub and last-test result contract is available for operator visibility.
- API client establishes header, timeout, SSL and error-handling pattern for future Erli calls.
**Concerns:**
- Real credentials and migration smoke remain manual follow-up.
- `/inbox` test endpoint checks authorization but does not verify order payload mapping yet.
**Blockers:**
- None for planning Phase 128.
---
*Phase: 127-erli-integration-foundation, Plan: 01*
*Completed: 2026-05-15*

View File

@@ -39,7 +39,7 @@ HTTP Request
| **Accounting** | 5 | `AccountingController`, `ReceiptService`, `ReceiptRepository` | Receipts, invoices, PDF, Excel export |
| **Email** | 3 | `EmailSendingService`, `VariableResolver`, `AttachmentGenerator` | Template-based email with PDF attachments |
| **Automation** | 6 | `AutomationService` (834 LOC), `AutomationRepository`, `AutomationExecutionLogRepository` | Event→condition→action rules, email triggers |
| **Settings** | 51+ | Integration controllers, OAuth clients, API clients, mappers | Allegro/shopPRO/Apaczka/InPost config, status mappings |
| **Settings** | 57+ | Integration controllers, OAuth clients, API clients, mappers | Allegro/shopPRO/Erli/Apaczka/InPost config, status mappings |
| **Sms** | 3 | `SmsMessageRepository`, `SmsConversationService`, `SmsplanetWebhookController` | SMSPLANET outbound order SMS, inbound webhook parsing, order matching |
| **Notifications** | 3 | `NotificationRepository`, `NotificationController`, `NotificationApiController` | Global notification history, unread polling API, mark-read actions |
| **Cron** | 12 | `CronRepository`, `CronHandlerFactory`, handler classes | Scheduled imports, syncs, token refresh |
@@ -117,6 +117,13 @@ HTTP Request
| `OrderStatusAgedHandler` | Trigger automation for stuck statuses |
| `AutomationHistoryCleanupHandler` | Purge old automation logs |
### Erli Integration Foundation
1. **Settings** - `/settings/integrations/erli` stores one global Erli API key encrypted via `IntegrationSecretCipher`, an optional account label, active flag, and last connection-test result.
2. **Connection test** - `ErliIntegrationController::test()` loads active credentials, calls `ErliApiClient::testConnection()`, performs a real authenticated `GET https://erli.pl/svc/shop-api/inbox`, and stores the result in `integrations.last_test_*`.
3. **Hub** - `IntegrationsHubController::buildErliRow()` adds Erli to `/settings/integrations` with configured/missing secret status, active status, last test timestamp, and configure URL.
4. **Deferred** - Phase 127 does not import orders, sync statuses, create labels, or track shipments. Those flows are planned for v3.8 Phases 128-131.
## Dependency Injection
Manual constructor injection in `routes/web.php` — no DI container library. Example:
@@ -164,6 +171,30 @@ tests/
bootstrap.php PSR-4 autoloader for tests
```
## Phase 127 - Erli Integration Foundation
### ErliIntegrationRepository (`src/Modules/Settings/ErliIntegrationRepository.php`)
- Zarzadza pojedynczym rekordem `erli_integration_settings` (`id=1`) i bazowym wpisem `integrations.type='erli'`.
- Szyfruje klucz API przez `IntegrationSecretCipher`; formularz widzi tylko flage `has_api_key`.
- `getCredentials()` zwraca aktywna konfiguracje z `base_url='https://erli.pl/svc/shop-api'` i odszyfrowanym Bearer API key.
- Pusty input `api_key` podczas edycji zachowuje zapisany sekret.
### ErliApiClient (`src/Modules/Settings/ErliApiClient.php`)
- `testConnection()` wykonuje realny `GET /inbox` do Erli z naglowkiem `Authorization: Bearer ...`.
- Wysyla `Accept: application/json` i `User-Agent: orderPRO/1.0 (erli-integration)`.
- Traktuje HTTP 2xx jako sukces; 401/403 jako blad autoryzacji, 429 jako limit zapytan, pozostale bledy jako czytelny komunikat z odpowiedzi.
- Uzywa `SslCertificateResolver` i nie wywoluje `curl_close()` (PHP 8.5 compatible).
### ErliIntegrationController (`src/Modules/Settings/ErliIntegrationController.php`)
- Endpointy: `GET /settings/integrations/erli`, `POST /settings/integrations/erli/save`, `POST /settings/integrations/erli/test`.
- `save` zapisuje label, aktywnosc i sekret; `test` wykonuje realny test API i zapisuje wynik przez `IntegrationsRepository::updateTestResult()`.
### IntegrationsHubController
- Dodaje wiersz Erli do `/settings/integrations` ze statusem konfiguracji, sekretu, aktywnosci i ostatniego testu.
### Scope Boundary
- Phase 127 nie dodaje importu zamowien, mapowania/synchronizacji statusow, etykiet ani trackingu Erli. Te obszary sa odlozone do Phases 128-131.
## Phase 116 - HostedSMS Integration Settings
### HostedSmsIntegrationRepository (`src/Modules/Settings/HostedSmsIntegrationRepository.php`)

View File

@@ -1,6 +1,6 @@
# Database Schema
**Updated:** 2026-05-12 | **Total tables:** 62 | **Engine:** InnoDB | **Charset:** utf8mb4_unicode_ci
**Updated:** 2026-05-15 | **Total tables:** 63 | **Engine:** InnoDB | **Charset:** utf8mb4_unicode_ci
---
@@ -613,6 +613,20 @@ UNIQUE: `(integration_id)` - one global SMSPLANET settings row.
---
**erli_integration_settings** - Erli marketplace API credentials (Phase 127; fixed 1 row)
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | TINYINT UNSIGNED | NO | PK, always 1 |
| `integration_id` | INT UNSIGNED | YES | UNIQUE, FK -> integrations(id) CASCADE; single `integrations.type='erli'` row |
| `api_key_encrypted` | TEXT | YES | AES-encrypted Bearer API key via `IntegrationSecretCipher` |
| `account_label` | VARCHAR(128) | YES | Optional display label for the integrations hub |
| `created_at` | DATETIME | NO | DEFAULT CURRENT_TIMESTAMP |
| `updated_at` | DATETIME | NO | DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP |
UNIQUE: `(integration_id)` - one global Erli settings row. Base integration uses `base_url='https://erli.pl/svc/shop-api'`. Phase 127 only stores configuration and test results; order import, status sync, shipments and tracking are deferred to later v3.8 phases.
---
**sms_messages** - SMSPLANET inbound/outbound conversation history (Phase 121)
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|

View File

@@ -1,5 +1,22 @@
# Technical Changelog
## 2026-05-15 - Phase 127 Plan 01: Erli Integration Foundation
**Co zrobiono:**
- Dodano migracje `20260515_000114_create_erli_integration_settings.sql` z pojedyncza globalna konfiguracja `erli_integration_settings` i bazowym wpisem `integrations.type='erli'`.
- Dodano `ErliIntegrationRepository` z szyfrowaniem klucza API przez `IntegrationSecretCipher` i zachowaniem sekretu przy pustym polu edycji.
- Dodano `ErliApiClient`, ktory testuje polaczenie realnym `GET https://erli.pl/svc/shop-api/inbox` z naglowkiem `Authorization: Bearer ...` i `User-Agent`.
- Dodano `ErliIntegrationController`, routes `/settings/integrations/erli`, `/save`, `/test` oraz widok `resources/views/settings/erli.php`.
- Dodano Erli do hubu integracji `/settings/integrations` z informacja o konfiguracji, aktywnosci i ostatnim tescie.
**Dlaczego:**
- Erli ma byc trzecim kanalem sprzedazy, ale potrzebuje najpierw bezpiecznego fundamentu konfiguracji i potwierdzenia dostepu do API.
- Na podstawie decyzji operatora integracja startuje jako jedna globalna konfiguracja, bez przelacznika sandbox.
- Test polaczenia realnie odpytuje API, ale nie importuje zamowien i nie oznacza inboxa jako przeczytanego.
**BREAKING / migracja:**
- Brak breaking changes. Nowa tabela i nowy wpis integracji sa dodatkiem. Import zamowien, synchronizacja statusow, etykiety i tracking Erli sa odlozone do kolejnych faz v3.8.
## 2026-05-12 - SMSPLANET Inbound Webhook Fix
**Co zrobiono:**

View File

@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS `erli_integration_settings` (
`id` TINYINT UNSIGNED NOT NULL PRIMARY KEY,
`integration_id` INT UNSIGNED NULL,
`api_key_encrypted` TEXT NULL,
`account_label` VARCHAR(128) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `erli_integration_settings_integration_unique` (`integration_id`),
CONSTRAINT `erli_integration_settings_integration_fk`
FOREIGN KEY (`integration_id`) REFERENCES `integrations` (`id`)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO `integrations` (`type`, `name`, `base_url`, `timeout_seconds`, `is_active`, `created_at`, `updated_at`)
VALUES ('erli', 'Erli', 'https://erli.pl/svc/shop-api', 15, 1, NOW(), NOW())
ON DUPLICATE KEY UPDATE
`base_url` = VALUES(`base_url`),
`timeout_seconds` = VALUES(`timeout_seconds`),
`updated_at` = VALUES(`updated_at`);
INSERT INTO `erli_integration_settings` (`id`, `integration_id`, `created_at`, `updated_at`)
SELECT 1, `id`, NOW(), NOW()
FROM `integrations`
WHERE `type` = 'erli' AND `name` = 'Erli'
LIMIT 1
ON DUPLICATE KEY UPDATE
`integration_id` = VALUES(`integration_id`),
`updated_at` = VALUES(`updated_at`);

View File

@@ -586,6 +586,7 @@ return [
'shoppro' => 'shopPRO',
'hostedsms' => 'HostedSMS',
'smsplanet' => 'SMSPLANET',
'erli' => 'Erli',
'shoppro_instances' => ':count instancji',
],
'status' => [
@@ -859,6 +860,47 @@ return [
'test_failed' => 'Nie udalo sie wyslac testowego SMS.',
],
],
'erli' => [
'title' => 'Integracja Erli',
'description' => 'Konfiguracja globalnego polaczenia z marketplace Erli.',
'config' => [
'title' => 'Konfiguracja API',
],
'test' => [
'title' => 'Test polaczenia',
'description' => 'Test wykonuje realne, bezpieczne zapytanie GET do API Erli.',
],
'fields' => [
'account_label' => 'Nazwa konta',
'api_key' => 'Klucz API',
'options' => 'Opcje',
'is_active' => 'Integracja aktywna',
],
'api_key' => [
'saved' => 'Klucz API jest zapisany. Pozostaw pole puste, aby nie zmieniac.',
'missing' => 'Brak zapisanego klucza API Erli.',
],
'hints' => [
'account_label' => 'Opcjonalna nazwa widoczna w hubie integracji.',
],
'status' => [
'secret' => 'Sekret API',
'active' => 'Aktywna',
'saved' => 'zapisany',
'missing' => 'brak',
'last_test' => 'Ostatni test',
],
'actions' => [
'save' => 'Zapisz ustawienia Erli',
'test' => 'Test polaczenia',
],
'flash' => [
'saved' => 'Ustawienia Erli zostaly zapisane.',
'save_failed' => 'Nie udalo sie zapisac ustawien Erli.',
'test_success' => 'Polaczenie z API Erli dziala.',
'test_failed' => 'Nie udalo sie polaczyc z API Erli.',
],
],
'inpost' => [
'title' => 'Integracja InPost',
'description' => 'Konfiguracja polaczenia z API InPost ShipX do obslugi przesylek.',

View File

@@ -0,0 +1,95 @@
<?php
$settings = is_array($settings ?? null) ? $settings : [];
$accountLabel = trim((string) ($settings['account_label'] ?? ''));
$hasApiKey = (bool) ($settings['has_api_key'] ?? false);
$isActive = (bool) ($settings['is_active'] ?? true);
$lastTestAt = trim((string) ($settings['last_test_at'] ?? ''));
$lastTestStatus = trim((string) ($settings['last_test_status'] ?? ''));
$lastTestMessage = trim((string) ($settings['last_test_message'] ?? ''));
$lastTestHttpCode = $settings['last_test_http_code'] ?? null;
?>
<section class="card">
<h2 class="section-title"><?= $e($t('settings.erli.title')) ?></h2>
<p class="muted mt-12"><?= $e($t('settings.erli.description')) ?></p>
<?php if (!empty($errorMessage)): ?>
<div class="mt-12"><?php $type='danger'; $message=(string) $errorMessage; $dismissible=true; include dirname(__DIR__) . '/components/alert.php'; ?></div>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<div class="mt-12"><?php $type='success'; $message=(string) $successMessage; $dismissible=true; include dirname(__DIR__) . '/components/alert.php'; ?></div>
<?php endif; ?>
<?php if (!empty($testMessage)): ?>
<div class="mt-12"><?php $type='info'; $message=(string) $testMessage; $dismissible=true; include dirname(__DIR__) . '/components/alert.php'; ?></div>
<?php endif; ?>
</section>
<section class="card mt-16">
<h3 class="section-title"><?= $e($t('settings.erli.config.title')) ?></h3>
<div class="muted mt-12">
<?= $e($t('settings.erli.status.secret')) ?>:
<strong><?= $e($hasApiKey ? $t('settings.erli.status.saved') : $t('settings.erli.status.missing')) ?></strong>
|
<?= $e($t('settings.erli.status.active')) ?>:
<strong><?= $e($isActive ? $t('settings.integrations_hub.active.yes') : $t('settings.integrations_hub.active.no')) ?></strong>
</div>
<form class="statuses-form mt-16" action="/settings/integrations/erli/save" method="post" novalidate>
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<label class="form-field">
<span class="field-label"><?= $e($t('settings.erli.fields.account_label')) ?></span>
<input class="form-control" type="text" name="account_label" maxlength="128" value="<?= $e($accountLabel) ?>">
<span class="muted"><?= $e($t('settings.erli.hints.account_label')) ?></span>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.erli.fields.api_key')) ?></span>
<input class="form-control" type="password" name="api_key" autocomplete="new-password" placeholder="<?= $hasApiKey ? '********' : '' ?>">
<span class="muted"><?= $e($hasApiKey ? $t('settings.erli.api_key.saved') : $t('settings.erli.api_key.missing')) ?></span>
</label>
<fieldset class="integration-settings-checkboxes">
<legend class="field-label"><?= $e($t('settings.erli.fields.options')) ?></legend>
<div class="integration-settings-checkboxes__list">
<label class="integration-settings-checkboxes__item">
<input type="checkbox" name="is_active" value="1"<?= $isActive ? ' checked' : '' ?>>
<span><?= $e($t('settings.erli.fields.is_active')) ?></span>
</label>
</div>
</fieldset>
<div class="form-actions mt-16">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.erli.actions.save')) ?></button>
</div>
</form>
</section>
<section class="card mt-16">
<h3 class="section-title"><?= $e($t('settings.erli.test.title')) ?></h3>
<p class="muted mt-12"><?= $e($t('settings.erli.test.description')) ?></p>
<form class="statuses-form mt-16" action="/settings/integrations/erli/test" method="post">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<div class="form-actions">
<button type="submit" class="btn btn--secondary"><?= $e($t('settings.erli.actions.test')) ?></button>
</div>
</form>
<?php if ($lastTestAt !== ''): ?>
<div class="mt-16"><?php
$type = $lastTestStatus === 'ok' ? 'success' : 'danger';
$messageHtml = '<strong>' . $e($t('settings.erli.status.last_test')) . ':</strong> '
. $e($lastTestAt)
. ($lastTestStatus !== '' ? ' <span class="badge badge--' . ($lastTestStatus === 'ok' ? 'success' : 'muted') . '">' . $e(strtoupper($lastTestStatus)) . '</span>' : '')
. ($lastTestHttpCode !== null ? ' <span class="badge badge--muted">HTTP ' . $e((string) $lastTestHttpCode) . '</span>' : '')
. ($lastTestMessage !== '' ? '<div class="mt-12">' . $e($lastTestMessage) . '</div>' : '');
$dismissible = false;
include dirname(__DIR__) . '/components/alert.php';
unset($messageHtml);
?></div>
<?php endif; ?>
</section>

View File

@@ -29,6 +29,9 @@ use App\Modules\Settings\ApaczkaApiClient;
use App\Modules\Settings\ApaczkaIntegrationController;
use App\Modules\Settings\ApaczkaIntegrationRepository;
use App\Modules\Settings\CarrierDeliveryMethodMappingRepository;
use App\Modules\Settings\ErliApiClient;
use App\Modules\Settings\ErliIntegrationController;
use App\Modules\Settings\ErliIntegrationRepository;
use App\Modules\Settings\FakturowniaApiClient;
use App\Modules\Settings\FakturowniaIntegrationController;
use App\Modules\Settings\FakturowniaIntegrationRepository;
@@ -229,6 +232,18 @@ return static function (Application $app): void {
new SmsplanetApiClient(),
new IntegrationsRepository($app->db())
);
$erliIntegrationRepository = new ErliIntegrationRepository(
$app->db(),
(string) $app->config('app.integrations.secret', '')
);
$erliIntegrationController = new ErliIntegrationController(
$template,
$translator,
$auth,
$erliIntegrationRepository,
new ErliApiClient(),
new IntegrationsRepository($app->db())
);
$notificationRepository = new NotificationRepository($app->db());
$smsMessageRepository = new SmsMessageRepository($app->db());
$smsConversationService = new SmsConversationService(
@@ -251,7 +266,8 @@ return static function (Application $app): void {
$shopproIntegrationsRepository,
$fakturowniaIntegrationRepository,
$hostedSmsIntegrationRepository,
$smsplanetIntegrationRepository
$smsplanetIntegrationRepository,
$erliIntegrationRepository
);
$cronSettingsController = new CronSettingsController(
$template,
@@ -616,6 +632,9 @@ return static function (Application $app): void {
$router->get('/settings/integrations/smsplanet', [$smsplanetIntegrationController, 'index'], [$authMiddleware]);
$router->post('/settings/integrations/smsplanet/save', [$smsplanetIntegrationController, 'save'], [$authMiddleware]);
$router->post('/settings/integrations/smsplanet/test', [$smsplanetIntegrationController, 'test'], [$authMiddleware]);
$router->get('/settings/integrations/erli', [$erliIntegrationController, 'index'], [$authMiddleware]);
$router->post('/settings/integrations/erli/save', [$erliIntegrationController, 'save'], [$authMiddleware]);
$router->post('/settings/integrations/erli/test', [$erliIntegrationController, 'test'], [$authMiddleware]);
$router->get('/settings/integrations/shoppro', [$shopproIntegrationsController, 'index'], [$authMiddleware]);
$router->post('/settings/integrations/shoppro/save', [$shopproIntegrationsController, 'save'], [$authMiddleware]);
$router->post('/settings/integrations/shoppro/test', [$shopproIntegrationsController, 'test'], [$authMiddleware]);

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Http\SslCertificateResolver;
final class ErliApiClient
{
public function __construct(private readonly int $timeoutSeconds = 15)
{
}
/**
* @param array{base_url: string, api_key: string} $credentials
* @return array{ok: bool, http_code: int, message: string}
*/
public function testConnection(array $credentials): array
{
$baseUrl = rtrim(trim((string) ($credentials['base_url'] ?? '')), '/');
$apiKey = trim((string) ($credentials['api_key'] ?? ''));
if ($baseUrl === '' || $apiKey === '') {
return [
'ok' => false,
'http_code' => 0,
'message' => 'Brak adresu API lub klucza API Erli.',
];
}
[$body, $httpCode, $curlError] = $this->httpGet($baseUrl . '/inbox', $apiKey);
if ($curlError !== null) {
return [
'ok' => false,
'http_code' => $httpCode,
'message' => 'Blad polaczenia: ' . $curlError,
];
}
if ($httpCode >= 200 && $httpCode < 300) {
return [
'ok' => true,
'http_code' => $httpCode,
'message' => 'OK',
];
}
return [
'ok' => false,
'http_code' => $httpCode,
'message' => $this->resolveFailureMessage($body, $httpCode),
];
}
/**
* @return array{0: string, 1: int, 2: ?string}
*/
private function httpGet(string $url, string $apiKey): array
{
$ch = curl_init($url);
if ($ch === false) {
return ['', 0, 'Nie udalo sie zainicjowac cURL.'];
}
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPGET => true,
CURLOPT_TIMEOUT => $this->timeoutSeconds,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'Accept: application/json',
'User-Agent: orderPRO/1.0 (erli-integration)',
],
];
$caPath = SslCertificateResolver::resolve();
if ($caPath !== null) {
$opts[CURLOPT_CAINFO] = $caPath;
}
curl_setopt_array($ch, $opts);
$rawBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
if ($rawBody === false) {
return ['', $httpCode, $curlError !== '' ? $curlError : 'Brak odpowiedzi z API.'];
}
return [(string) $rawBody, $httpCode, null];
}
private function resolveFailureMessage(string $body, int $httpCode): string
{
if ($httpCode === 401 || $httpCode === 403) {
return 'Blad autoryzacji Erli - sprawdz klucz API.';
}
if ($httpCode === 429) {
return 'Erli zwrocilo limit zapytan (HTTP 429). Sprobuj ponownie pozniej.';
}
$message = $this->extractMessage($body);
if ($message !== '') {
return $message;
}
return 'HTTP ' . $httpCode;
}
private function extractMessage(string $body): string
{
$trimmed = ltrim($body, "\xEF\xBB\xBF \t\n\r\0\x0B");
if ($trimmed === '') {
return '';
}
$decoded = json_decode($trimmed, true);
if (is_array($decoded)) {
foreach (['message', 'error', 'detail', 'title'] as $key) {
if (isset($decoded[$key]) && is_string($decoded[$key]) && trim($decoded[$key]) !== '') {
return substr(trim($decoded[$key]), 0, 255);
}
}
$encoded = json_encode($decoded, JSON_UNESCAPED_UNICODE);
if (is_string($encoded) && $encoded !== '') {
return substr($encoded, 0, 255);
}
}
return substr(trim(strip_tags($trimmed)), 0, 255);
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Exceptions\IntegrationConfigException;
use App\Core\Http\RedirectPathResolver;
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 Throwable;
final class ErliIntegrationController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly ErliIntegrationRepository $repository,
private readonly ErliApiClient $apiClient,
private readonly IntegrationsRepository $integrations
) {
}
public function index(Request $request): Response
{
$html = $this->template->render('settings/erli', [
'title' => $this->translator->get('settings.erli.title'),
'activeMenu' => 'settings',
'activeSettings' => 'integrations',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'settings' => $this->repository->getSettings(),
'errorMessage' => (string) Flash::get('settings_error', ''),
'successMessage' => (string) Flash::get('settings_success', ''),
'testMessage' => (string) Flash::get('erli_test', ''),
], 'layouts/app');
return Response::html($html);
}
public function save(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);
}
try {
$this->repository->saveSettings([
'account_label' => (string) $request->input('account_label', ''),
'api_key' => (string) $request->input('api_key', ''),
'is_active' => $request->input('is_active', ''),
]);
Flash::set('settings_success', $this->translator->get('settings.erli.flash.saved'));
} catch (Throwable $exception) {
Flash::set(
'settings_error',
$this->translator->get('settings.erli.flash.save_failed') . ' ' . $exception->getMessage()
);
}
return Response::redirect($redirectTo);
}
public function test(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);
}
try {
$credentials = $this->repository->getCredentials();
if ($credentials === null) {
throw new IntegrationConfigException('Najpierw zapisz kompletna i aktywna konfiguracje Erli.');
}
$result = $this->apiClient->testConnection($credentials);
$this->integrations->updateTestResult(
$credentials['integration_id'],
$result['ok'] ? 'ok' : 'fail',
(int) $result['http_code'],
(string) $result['message']
);
if ($result['ok']) {
Flash::set('erli_test', $this->translator->get('settings.erli.flash.test_success'));
} else {
Flash::set('settings_error', $this->translator->get('settings.erli.flash.test_failed') . ' ' . $result['message']);
}
} catch (Throwable $exception) {
Flash::set('settings_error', $this->translator->get('settings.erli.flash.test_failed') . ' ' . $exception->getMessage());
}
return Response::redirect($redirectTo);
}
private function resolveRedirect(Request $request): string
{
return RedirectPathResolver::resolve(
(string) $request->input('return_to', '/settings/integrations/erli'),
['/settings/integrations'],
'/settings/integrations/erli'
);
}
}

View File

@@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Exceptions\IntegrationConfigException;
use App\Core\Support\StringHelper;
use PDO;
use Throwable;
final class ErliIntegrationRepository
{
private const INTEGRATION_TYPE = 'erli';
private const INTEGRATION_NAME = 'Erli';
private const INTEGRATION_BASE_URL = 'https://erli.pl/svc/shop-api';
private readonly IntegrationsRepository $integrations;
private readonly IntegrationSecretCipher $cipher;
public function __construct(
private readonly PDO $pdo,
private readonly string $secret
) {
$this->integrations = new IntegrationsRepository($this->pdo);
$this->cipher = new IntegrationSecretCipher($this->secret);
}
/**
* @return array<string, mixed>
*/
public function getSettings(): array
{
$this->ensureRow();
$integrationId = $this->ensureBaseIntegration();
$row = $this->fetchRow() ?? [];
$integration = $this->integrations->findById($integrationId);
$encryptedApiKey = $this->resolveApiKeyEncrypted($row, $integration);
return [
'integration_id' => $integrationId,
'account_label' => trim((string) ($row['account_label'] ?? '')),
'has_api_key' => $encryptedApiKey !== null && $encryptedApiKey !== '',
'is_active' => (int) ($integration['is_active'] ?? 1) === 1,
'last_test_status' => trim((string) ($integration['last_test_status'] ?? '')),
'last_test_http_code' => isset($integration['last_test_http_code']) ? (int) $integration['last_test_http_code'] : null,
'last_test_message' => trim((string) ($integration['last_test_message'] ?? '')),
'last_test_at' => trim((string) ($integration['last_test_at'] ?? '')),
'updated_at' => trim((string) ($row['updated_at'] ?? '')),
];
}
/**
* @param array<string, mixed> $payload
*/
public function saveSettings(array $payload): void
{
$this->ensureRow();
$integrationId = $this->ensureBaseIntegration();
$row = $this->fetchRequiredRow();
$integration = $this->integrations->findById($integrationId);
$currentEncrypted = $this->resolveApiKeyEncrypted($row, $integration);
$apiKey = trim((string) ($payload['api_key'] ?? ''));
$nextEncrypted = $currentEncrypted;
if ($apiKey !== '') {
$nextEncrypted = $this->cipher->encrypt($apiKey);
}
if ($nextEncrypted === null || $nextEncrypted === '') {
throw new IntegrationConfigException('Podaj klucz API Erli.');
}
$accountLabel = $this->validateAccountLabel((string) ($payload['account_label'] ?? ''));
$statement = $this->pdo->prepare(
'UPDATE erli_integration_settings
SET api_key_encrypted = :api_key_encrypted,
account_label = :account_label,
updated_at = NOW()
WHERE id = 1'
);
$statement->execute([
'api_key_encrypted' => $nextEncrypted,
'account_label' => $accountLabel,
]);
$this->updateIntegrationActive($integrationId, !empty($payload['is_active']));
$this->integrations->updateApiKeyEncrypted($integrationId, $nextEncrypted);
}
/**
* @return array{integration_id: int, base_url: string, api_key: string}|null
*/
public function getCredentials(): ?array
{
$this->ensureRow();
$integrationId = $this->ensureBaseIntegration();
$row = $this->fetchRow();
$integration = $this->integrations->findById($integrationId);
if ($row === null || (int) ($integration['is_active'] ?? 0) !== 1) {
return null;
}
$encrypted = $this->resolveApiKeyEncrypted($row, $integration);
if ($encrypted === null || $encrypted === '') {
return null;
}
$apiKey = trim($this->cipher->decrypt($encrypted));
if ($apiKey === '') {
return null;
}
return [
'integration_id' => $integrationId,
'base_url' => trim((string) ($integration['base_url'] ?? self::INTEGRATION_BASE_URL)),
'api_key' => $apiKey,
];
}
private function ensureBaseIntegration(): int
{
return $this->integrations->ensureIntegration(
self::INTEGRATION_TYPE,
self::INTEGRATION_NAME,
self::INTEGRATION_BASE_URL,
15,
true
);
}
private function ensureRow(): void
{
$integrationId = $this->ensureBaseIntegration();
$statement = $this->pdo->prepare(
'INSERT INTO erli_integration_settings (id, integration_id, created_at, updated_at)
VALUES (1, :integration_id, NOW(), NOW())
ON DUPLICATE KEY UPDATE integration_id = VALUES(integration_id), updated_at = VALUES(updated_at)'
);
$statement->execute(['integration_id' => $integrationId]);
}
/**
* @return array<string, mixed>|null
*/
private function fetchRow(): ?array
{
try {
$statement = $this->pdo->prepare('SELECT * FROM erli_integration_settings WHERE id = 1 LIMIT 1');
$statement->execute();
$row = $statement->fetch(PDO::FETCH_ASSOC);
} catch (Throwable) {
return null;
}
return is_array($row) ? $row : null;
}
/**
* @return array<string, mixed>
*/
private function fetchRequiredRow(): array
{
$row = $this->fetchRow();
if ($row === null) {
throw new IntegrationConfigException('Brak rekordu konfiguracji Erli.');
}
return $row;
}
private function validateAccountLabel(string $value): ?string
{
$label = trim($value);
if ($label === '') {
return null;
}
if (mb_strlen($label) > 128) {
throw new IntegrationConfigException('Nazwa konta Erli jest za dluga (max 128 znakow).');
}
return $label;
}
/**
* @param array<string, mixed>|null $row
* @param array<string, mixed>|null $integration
*/
private function resolveApiKeyEncrypted(?array $row, ?array $integration): ?string
{
$settingsValue = trim((string) ($row['api_key_encrypted'] ?? ''));
if ($settingsValue !== '') {
return $settingsValue;
}
$baseValue = trim((string) ($integration['api_key_encrypted'] ?? ''));
return StringHelper::nullableString($baseValue);
}
private function updateIntegrationActive(int $integrationId, bool $isActive): void
{
$statement = $this->pdo->prepare(
'UPDATE integrations
SET name = :name,
base_url = :base_url,
timeout_seconds = :timeout_seconds,
is_active = :is_active,
updated_at = NOW()
WHERE id = :id AND type = :type'
);
$statement->execute([
'id' => $integrationId,
'type' => self::INTEGRATION_TYPE,
'name' => self::INTEGRATION_NAME,
'base_url' => self::INTEGRATION_BASE_URL,
'timeout_seconds' => 15,
'is_active' => $isActive ? 1 : 0,
]);
}
}

View File

@@ -24,7 +24,8 @@ final class IntegrationsHubController
private readonly ShopproIntegrationsRepository $shoppro,
private readonly FakturowniaIntegrationRepository $fakturownia,
private readonly HostedSmsIntegrationRepository $hostedSms,
private readonly SmsplanetIntegrationRepository $smsplanet
private readonly SmsplanetIntegrationRepository $smsplanet,
private readonly ErliIntegrationRepository $erli
) {
}
@@ -39,6 +40,7 @@ final class IntegrationsHubController
$this->buildFakturowniaRow(),
$this->buildHostedSmsRow(),
$this->buildSmsplanetRow(),
$this->buildErliRow(),
];
$html = $this->template->render('settings/integrations', [
@@ -253,4 +255,30 @@ final class IntegrationsHubController
];
}
/**
* @return array<string, mixed>
*/
private function buildErliRow(): array
{
$settings = $this->erli->getSettings();
$isConfigured = !empty($settings['has_api_key']);
return [
'provider' => $this->translator->get('settings.integrations_hub.providers.erli'),
'instance' => trim((string) ($settings['account_label'] ?? '')) !== ''
? trim((string) $settings['account_label'])
: 'Erli',
'authorization_status' => $isConfigured
? $this->translator->get('settings.integrations_hub.status.configured')
: $this->translator->get('settings.integrations_hub.status.not_configured'),
'secret_status' => $isConfigured
? $this->translator->get('settings.integrations_hub.status.saved')
: $this->translator->get('settings.integrations_hub.status.missing'),
'is_active' => !empty($settings['is_active']),
'last_test_at' => trim((string) ($settings['last_test_at'] ?? '')),
'configure_url' => '/settings/integrations/erli',
'configure_label' => $this->translator->get('settings.integrations_hub.actions.configure'),
];
}
}