diff --git a/.paul/PROJECT.md b/.paul/PROJECT.md index 5a3a4a0..30135d7 100644 --- a/.paul/PROJECT.md +++ b/.paul/PROJECT.md @@ -13,8 +13,8 @@ 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-117 shipped (Fakturownia + HostedSMS/SMSPLANET settings/test SMS) | -| Last Updated | 2026-05-12 (Phase 120 closed) | +| Status | v3.7 in progress — Phases 113-124 shipped (Fakturownia + HostedSMS/SMSPLANET + Alert unify + receipt VAT + SMS templates) | +| Last Updated | 2026-05-13 (Phase 124 closed) | ## Requirements @@ -124,6 +124,7 @@ Sprzedawca moĹĽe obsĹ‚ugiwać zamĂłwienia ze wszystkich kanałów - [x] Re-import ochrona `total_paid`: gdy `payment_status` sie nie zmienia, `updateOrderDelta()` nie nadpisuje `total_paid` (ani `is_canceled_by_buyer`, chyba ze cancel ze zrodla); chroni reczne korekty operatora (zwroty czesciowe). Dynamic SQL SET builder + 3 testy PHPUnit (Reflection + sqlite) — Phase 119 - [x] Ujednolicony moduł alertów UI: reusable PHP komponent `resources/views/components/alert.php` z inline SVG ikoną per typ (info/success/warning/danger), opcjonalnym dismiss button (vanilla JS, idempotent); brakujący `.alert--info` (#eff6ff/#bfdbfe/#1e3a8a); `Flash::push/all` z BC dla `set/get` (heurystyka klucza legacy); centralny renderer flash w 3 layoutach (app/auth/public); 36 widoków zmigrowanych off inline alert markup; `.flash--*` usunięte z widoków — Phase 120 - [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 ### Deferred diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md index d7e36d0..fdf3166 100644 --- a/.paul/ROADMAP.md +++ b/.paul/ROADMAP.md @@ -23,6 +23,7 @@ Wystawianie faktur dla klientow z NIP poprzez integracje z Fakturownia (app.fakt | 121 | SMSPLANET Conversation + Notifications | 1/1 | Complete (2026-05-12; live SMS/browser smoke pending operator) | | 122 | SMSPLANET Default SMS Footer | 1/1 | Complete (2026-05-12; live SMS smoke + over-limit UI test pending operator) | | 123 | Receipts Export VAT Breakdown | 1/1 | Complete (2026-05-12; manual XLSX smoke pending operator) | +| 124 | SMS Templates | 1/1 | Complete (2026-05-13; migration + manual SMS smoke pending operator) | Planowane kolejne fazy v3.7 (kandydaci, do rozplanowania): - Eksport XLSX listy wystawionych faktur (analogicznie do paragonow) diff --git a/.paul/STATE.md b/.paul/STATE.md index 8767088..122c72c 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -5,19 +5,19 @@ 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 123 Receipts Export VAT Breakdown complete (UNIFY closed). +**Current focus:** v3.7 Invoices + operational integrations - Phase 124 SMS Templates complete (UNIFY closed). ## Current Position Milestone: v3.7 Invoices (Fakturownia integration) - In progress -Phase: 123 of TBD (Receipts Export VAT Breakdown) - Complete -Plan: 123-01 complete +Phase: 124 of TBD (SMS Templates) - Complete +Plan: 124-01 complete Status: UNIFY complete, ready to plan next phase -Last activity: 2026-05-12 23:00:00 - UNIFY closed for .paul/phases/123-receipts-export-vat-breakdown/123-01-PLAN.md +Last activity: 2026-05-13 00:30:00 - UNIFY closed for .paul/phases/124-sms-templates/124-01-PLAN.md Progress: -- Milestone v3.7: [#########-] ~97% (Phase 113-123 complete) -- Phase 123: [##########] 100% +- Milestone v3.7: [##########] ~98% (Phase 113-124 complete) +- Phase 124: [##########] 100% ## Loop Position @@ -29,9 +29,9 @@ PLAN -> APPLY -> UNIFY ## Session Continuity -Last session: 2026-05-12 22:00:00 -Stopped at: Phase 122 UNIFY closed (transition + git commit pending; worktree dirty with Phase 118 files) -Next action: Resolve Phase 118 UNIFY/commit, then scope a clean Phase 122 commit and pick next v3.7 phase +Last session: 2026-05-13 00:30:00 +Stopped at: Phase 124 UNIFY closed (UI fixes accepted by operator) +Next action: Pick next v3.7 phase (kandydaci w ROADMAP) or transition do v3.8 Resume file: .paul/ROADMAP.md ## Pending parallel work @@ -58,6 +58,9 @@ Note: routes/web.php, DOCS/* i .paul/codebase/* zawierały zmiany z 118+121+122 - Phase 122 follow-up: manually verify settings save/reload and real SMSPLANET test/order sends with non-empty and empty footer; manually trigger over-limit final body rejection in UI. - Phase 123 follow-up: wystaw nowy paragon i potwierdz `items_json` zawiera `vat` per pozycja; eksport XLSX z paragonem multi-rate (np. mix 23% + 8%) — sprawdz osobne wiersze; eksport "wybrane paragony" zachowuje breakdown. - Phase 123 deferred: RECEIPT-NET-FIX (`ReceiptService::issue()` zapisuje `total_net=total_gross`) — udokumentowane w `.paul/codebase/todo.md`. +- Phase 124 follow-up: `php bin/migrate.php` (XAMPP MySQL online) — utworzy `sms_templates`. Operator nastepnie tworzy szablony manualnie z `/settings/sms-templates`. +- Phase 124 follow-up: real smoke wysylki SMS z szablonu (zamowienie z paczka + skonfigurowana stopka SMSPLANET) — sprawdzic ze `sms_messages.body` ma stopke raz, finalna tresc <= 918 znakow. +- Phase 124 follow-up: regresja Email — wyslij e-mail z istniejacym szablonem aby potwierdzic ze refaktor `Email\VariableResolver` na fasade nie zlamal `EmailSendingService`. - 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. ## Deferred to Next Milestones diff --git a/.paul/changelog/2026-05-13.md b/.paul/changelog/2026-05-13.md new file mode 100644 index 0000000..6399139 --- /dev/null +++ b/.paul/changelog/2026-05-13.md @@ -0,0 +1,37 @@ +# 2026-05-13 + +## Co zrobiono + +- [Phase 124, Plan 01] Wdrozono szablony SMS: CRUD w `/settings/sms-templates` (name + body + is_active) plus dropdown "Wybierz szablon" w zakladce SMS na `/orders/{id}` wstawiajacy tresc z rozwinietymi zmiennymi `{{zamowienie.numer}}`, `{{kupujacy.imie_nazwisko}}`, `{{przesylka.numer}}` itd. do textarea. +- [Phase 124, Plan 01] Wydzielono `Sms\SmsVariableResolver` ze wspolna logika `buildVariableMap` + `resolve`; `Email\VariableResolver` zostal cienka fasada delegujaca — `EmailSendingService` niezmieniony, kontrakt Phase 14 zachowany. +- [Phase 124, Plan 01] Dodano endpoint `GET /orders/{id}/sms/template?template_id=N` (JSON z rozwinietym body per zamowienie); JS module `sms-template-picker.js` z idempotentnym guard i `OrderProAlerts.confirm` (options-object API) przy nadpisaniu niepustej textarea. +- [Phase 124, Plan 01] Migracja `20260512_000112_create_sms_templates.sql` (CREATE TABLE, DDL). Stopka SMSPLANET dalej doklejana wylacznie przez `SmsConversationService::buildFinalOutboundBody()` (Phase 122 contract preserved). +- [Phase 124, Plan 01] UI fixes po UAT operatora: paleta zmiennych przeniesiona pod textarea z pill chipami `{{var}} + opis` (border-radius 999px, hover indigo); akcje w liscie szablonow uzywaja `display: flex; flex-wrap: nowrap` zamiast `white-space: nowrap` (form-children byly blokowe). +- [Phase 124, Plan 01] UNIFY zamkniety; `php bin/migrate.php` i real smoke wysylki SMS z szablonu zalezne od XAMPP MySQL online. + +## Zmienione pliki + +- `database/migrations/20260512_000112_create_sms_templates.sql` +- `src/Modules/Sms/SmsTemplateRepository.php` +- `src/Modules/Sms/SmsVariableResolver.php` +- `src/Modules/Email/VariableResolver.php` +- `src/Modules/Settings/SmsTemplateController.php` +- `src/Modules/Orders/OrdersController.php` +- `routes/web.php` +- `resources/views/settings/sms-templates.php` +- `resources/views/settings/sms-templates-form.php` +- `resources/views/orders/show.php` +- `resources/views/layouts/app.php` +- `resources/lang/pl.php` +- `resources/scss/app.scss` +- `resources/scss/modules/_sms-templates.scss` +- `public/assets/css/app.css` +- `public/assets/js/modules/sms-template-picker.js` +- `.paul/codebase/db_schema.md` +- `.paul/codebase/architecture.md` +- `.paul/codebase/tech_changelog.md` +- `.paul/PROJECT.md` +- `.paul/ROADMAP.md` +- `.paul/STATE.md` +- `.paul/phases/124-sms-templates/124-01-PLAN.md` +- `.paul/phases/124-sms-templates/124-01-SUMMARY.md` diff --git a/.paul/codebase/architecture.md b/.paul/codebase/architecture.md index 1eb6d6d..e61f934 100644 --- a/.paul/codebase/architecture.md +++ b/.paul/codebase/architecture.md @@ -414,3 +414,61 @@ tests/ - Edycja przez ``, toggle/delete przez `
` z `_token` i `js-confirm-delete`. - Wspolny pattern miedzy `accounting-receipts.php` i `accounting-invoices.php` (faktury maja dodatkowe kolumny: Tryb, Konto Fakturowni). + +## Phase 124 — SMS Templates + +### SmsTemplateRepository (`src/Modules/Sms/SmsTemplateRepository.php`) +- CRUD na `sms_templates` (PDO prepared statements, ToggleableRepositoryTrait). +- `listAll()` (cala lista alfabetycznie po `name`), `listActive()` (tylko is_active=1, kolumny `id|name|body` do dropdownu w UI). +- `save(array): int` waliduje wymagane `name` + `body` (rzuca `RuntimeException` gdy puste); wykonuje INSERT albo UPDATE wg obecnosci `id` w payloadzie; zwraca id rekordu. +- `delete(int)`, `toggleStatus(int)` przez `toggleActive('sms_templates', $id)`. + +### SmsVariableResolver (`src/Modules/Sms/SmsVariableResolver.php`) +- Wydzielony z `Email\VariableResolver` — wspolna logika zmiennych dla Email i SMS. +- `buildVariableMap(order, addresses, companySettings)` zwraca mape placeholderow: `zamowienie.*`, `kupujacy.*`, `adres.*`, `firma.*`, `przesylka.*` (`przesylka.numer`/`przesylka.link_sledzenia` z najnowszej paczki przez `ShipmentPackageRepository::findLatestByOrderId` + `DeliveryStatus::trackingUrl`). +- `resolve(template, variableMap)` zastepuje `{{group.var}}` wartoscia z mapy (puste gdy brak klucza). + +### Email\VariableResolver (refaktor) +- Pozostaje final class z tym samym API publicznym (`buildVariableMap`/`resolve`) — `EmailSendingService` niezmieniony. +- Konstruktor: `(ShipmentPackageRepository $repo, ?SmsVariableResolver $inner = null)`. Gdy `$inner` nie podany, sam tworzy SmsVariableResolver — backward compat dla starego wiringu. +- Metody publiczne deleguja do `$this->inner` — zero duplikacji logiki zmiennych. + +### SmsTemplateController (`src/Modules/Settings/SmsTemplateController.php`) +- Mirror `EmailTemplateController` bez Quill/skrzynki/zalacznika/duplikacji. +- Akcje: `index` (lista), `create`/`edit`/`save` (form CRUD), `delete`, `toggleStatus` (AJAX JSON), `getVariables` (JSON paleta dla ewentualnego dynamic palette). +- `VARIABLE_GROUPS` jako stala klasy — pelne 5 grup (zamowienie/kupujacy/adres/firma/przesylka) zgodnie ze wspolnym SmsVariableResolver. +- Routy: `/settings/sms-templates`, `/create`, `/edit`, `/save`, `/delete`, `/toggle`, `/variables`. CSRF `_token` na POST. Flash `settings.sms_templates.success|error`. + +### OrdersController (rozszerzenie) +- Dodane optional params konstruktora: `?SmsTemplateRepository $smsTemplates`, `?SmsVariableResolver $smsVariableResolver`, `?CompanySettingsRepository $companySettingsRepo` (po istniejacych SMS params; default null = backward compat). +- `show()` przekazuje `$smsTemplates` (list active) do widoku jako `smsTemplates`. +- Nowa metoda `smsTemplate(Request)` -> `GET /orders/{id}/sms/template?template_id=N` -> JSON `{ok, body, name}` z rozwinietymi zmiennymi. 400/404/500 dla nieprawidlowych parametrow/braku rekordu. + +### Widok `orders/show.php` +- Nad textarea `name="message"` (`#js-sms-message`) dodany conditional ` + diff --git a/resources/views/settings/sms-templates-form.php b/resources/views/settings/sms-templates-form.php new file mode 100644 index 0000000..a764cac --- /dev/null +++ b/resources/views/settings/sms-templates-form.php @@ -0,0 +1,109 @@ + + +
+

+

Wpisz tresc wiadomosci ze zmiennymi typu {{zamowienie.numer}}. Stopka SMSPLANET jest doklejana automatycznie przy wysylce, nie dopisuj jej w szablonie.

+ + +
+ + +
+ +
+ +
+ + + + + + +
+ +
+ +
+
+ +
+ +
+ +
+
+ Dostepne zmienne + Kliknij chip, aby wstawic w pozycji kursora. +
+ $group): ?> +
+
+
+ $varDesc): ?> + + +
+
+ +
+ +
+ +
+ + diff --git a/resources/views/settings/sms-templates.php b/resources/views/settings/sms-templates.php new file mode 100644 index 0000000..8ce7881 --- /dev/null +++ b/resources/views/settings/sms-templates.php @@ -0,0 +1,110 @@ + + +
+
+

Szablony SMS

+ Dodaj szablon +
+

Szybkie szablony wiadomosci SMS do wstawiania z zakladki SMS w szczegolach zamowienia. Stopka SMSPLANET jest doklejana automatycznie.

+ + +
+ + +
+ +
+ +
+

Lista szablonow

+ + +

Brak szablonow. Kliknij "Dodaj szablon", aby utworzyc pierwszy.

+ +
+ + + + + + + + + + + + 80 : strlen($bodyPreview) > 80) { + $bodyPreview = (function_exists('mb_substr') ? mb_substr($bodyPreview, 0, 80) : substr($bodyPreview, 0, 80)) . '...'; + } + ?> + + + + + + + + +
NazwaTrescStatusAkcje
+ + Aktywny + + Nieaktywny + + + Edytuj + +
+ + + +
+
+
+ +
+ + diff --git a/routes/web.php b/routes/web.php index c3c589e..00fe8c8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -56,6 +56,7 @@ use App\Modules\Settings\EmailMailboxController; use App\Modules\Settings\EmailMailboxRepository; use App\Modules\Settings\EmailTemplateController; use App\Modules\Settings\EmailTemplateRepository; +use App\Modules\Settings\SmsTemplateController; use App\Modules\Settings\IntegrationSecretCipher; use App\Modules\Email\AttachmentGenerator; use App\Modules\Email\EmailSendingService; @@ -100,6 +101,8 @@ use App\Modules\Notifications\NotificationController; use App\Modules\Notifications\NotificationRepository; use App\Modules\Sms\SmsConversationService; use App\Modules\Sms\SmsMessageRepository; +use App\Modules\Sms\SmsTemplateRepository; +use App\Modules\Sms\SmsVariableResolver; use App\Modules\Sms\SmsplanetWebhookController; use App\Modules\Users\UsersController; @@ -315,6 +318,13 @@ return static function (Application $app): void { $emailTemplateRepository, $emailMailboxRepository ); + $smsTemplateRepository = new SmsTemplateRepository($app->db()); + $smsTemplateController = new SmsTemplateController( + $template, + $translator, + $auth, + $smsTemplateRepository + ); $automationRepository = new AutomationRepository($app->db()); $automationExecutionLogRepository = new AutomationExecutionLogRepository($app->db()); $automationController = new AutomationController( @@ -325,7 +335,8 @@ return static function (Application $app): void { $automationExecutionLogRepository, $receiptConfigRepository ); - $variableResolver = new VariableResolver($shipmentPackageRepositoryForOrders); + $smsVariableResolver = new SmsVariableResolver($shipmentPackageRepositoryForOrders); + $variableResolver = new VariableResolver($shipmentPackageRepositoryForOrders, $smsVariableResolver); $attachmentGenerator = new AttachmentGenerator($receiptRepository, $receiptConfigRepository, $template); $emailSendingService = new EmailSendingService( $app->db(), @@ -386,7 +397,7 @@ return static function (Application $app): void { $allegroDeliveryMappingController ); $printJobRepository = new PrintJobRepository($app->db()); - $ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository, $emailSendingService, $emailTemplateRepository, $emailMailboxRepository, $app->basePath('storage'), $printJobRepository, $shopproIntegrationsRepository, $automationService, $invoiceRepository, $invoiceConfigRepository, $smsMessageRepository, $smsConversationService); + $ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository, $emailSendingService, $emailTemplateRepository, $emailMailboxRepository, $app->basePath('storage'), $printJobRepository, $shopproIntegrationsRepository, $automationService, $invoiceRepository, $invoiceConfigRepository, $smsMessageRepository, $smsConversationService, $smsTemplateRepository, $smsVariableResolver, $companySettingsRepository); $ordersStatisticsController = new OrdersStatisticsController( $template, $translator, @@ -554,6 +565,7 @@ return static function (Application $app): void { $router->post('/orders/{id}/status', [$ordersController, 'updateStatus'], [$authMiddleware]); $router->post('/orders/{id}/details/update', [$ordersController, 'updateDetails'], [$authMiddleware]); $router->post('/orders/{id}/sms/send', [$ordersController, 'sendSms'], [$authMiddleware]); + $router->get('/orders/{id}/sms/template', [$ordersController, 'smsTemplate'], [$authMiddleware]); $router->post('/orders/{id}/send-email', [$ordersController, 'sendEmail'], [$authMiddleware]); $router->post('/orders/{id}/email-preview', [$ordersController, 'emailPreview'], [$authMiddleware]); $router->get('/api/orders/search', [$ordersController, 'quickSearch'], [$authMiddleware]); @@ -645,6 +657,13 @@ return static function (Application $app): void { $router->post('/settings/email-templates/toggle', [$emailTemplateController, 'toggleStatus'], [$authMiddleware]); $router->post('/settings/email-templates/preview', [$emailTemplateController, 'preview'], [$authMiddleware]); $router->get('/settings/email-templates/variables', [$emailTemplateController, 'getVariables'], [$authMiddleware]); + $router->get('/settings/sms-templates', [$smsTemplateController, 'index'], [$authMiddleware]); + $router->get('/settings/sms-templates/create', [$smsTemplateController, 'create'], [$authMiddleware]); + $router->get('/settings/sms-templates/edit', [$smsTemplateController, 'edit'], [$authMiddleware]); + $router->post('/settings/sms-templates/save', [$smsTemplateController, 'save'], [$authMiddleware]); + $router->post('/settings/sms-templates/delete', [$smsTemplateController, 'delete'], [$authMiddleware]); + $router->post('/settings/sms-templates/toggle', [$smsTemplateController, 'toggleStatus'], [$authMiddleware]); + $router->get('/settings/sms-templates/variables', [$smsTemplateController, 'getVariables'], [$authMiddleware]); $router->get('/settings/automation', [$automationController, 'index'], [$authMiddleware]); $router->get('/settings/automation/create', [$automationController, 'create'], [$authMiddleware]); $router->post('/settings/automation/store', [$automationController, 'store'], [$authMiddleware]); diff --git a/src/Modules/Email/VariableResolver.php b/src/Modules/Email/VariableResolver.php index d7f675b..b0e0856 100644 --- a/src/Modules/Email/VariableResolver.php +++ b/src/Modules/Email/VariableResolver.php @@ -4,14 +4,18 @@ declare(strict_types=1); namespace App\Modules\Email; -use App\Modules\Shipments\DeliveryStatus; use App\Modules\Shipments\ShipmentPackageRepository; +use App\Modules\Sms\SmsVariableResolver; final class VariableResolver { + private readonly SmsVariableResolver $inner; + public function __construct( - private readonly ShipmentPackageRepository $shipmentPackageRepository + ShipmentPackageRepository $shipmentPackageRepository, + ?SmsVariableResolver $inner = null ) { + $this->inner = $inner ?? new SmsVariableResolver($shipmentPackageRepository); } /** @@ -22,95 +26,14 @@ final class VariableResolver */ public function buildVariableMap(array $order, array $addresses, array $companySettings): array { - $customerAddress = $this->findAddress($addresses, 'customer'); - $deliveryAddress = $this->findAddress($addresses, 'delivery') ?? $customerAddress; - - $buyerName = (string) ($customerAddress['name'] ?? ''); - $buyerEmail = (string) ($customerAddress['email'] ?? ''); - $buyerPhone = (string) ($customerAddress['phone'] ?? ''); - - $totalFormatted = number_format((float) ($order['total_with_tax'] ?? 0), 2, ',', ' '); - $orderedAt = (string) ($order['ordered_at'] ?? ''); - if ($orderedAt !== '' && ($ts = strtotime($orderedAt)) !== false) { - $orderedAt = date('Y-m-d', $ts); - } - - $baseVariables = [ - 'zamowienie.numer' => (string) ($order['internal_order_number'] ?? $order['id'] ?? ''), - 'zamowienie.numer_zewnetrzny' => (string) ($order['external_order_id'] ?? $order['source_order_id'] ?? ''), - 'zamowienie.zrodlo' => ucfirst((string) ($order['source'] ?? '')), - 'zamowienie.kwota' => $totalFormatted, - 'zamowienie.waluta' => (string) ($order['currency'] ?? 'PLN'), - 'zamowienie.data' => $orderedAt, - 'kupujacy.imie_nazwisko' => $buyerName, - 'kupujacy.email' => $buyerEmail, - 'kupujacy.telefon' => $buyerPhone, - 'kupujacy.login' => (string) ($order['customer_login'] ?? ''), - 'adres.ulica' => trim(($deliveryAddress['street_name'] ?? '') . ' ' . ($deliveryAddress['street_number'] ?? '')), - 'adres.miasto' => (string) ($deliveryAddress['city'] ?? ''), - 'adres.kod_pocztowy' => (string) ($deliveryAddress['zip_code'] ?? ''), - 'adres.kraj' => (string) ($deliveryAddress['country'] ?? ''), - 'firma.nazwa' => (string) ($companySettings['company_name'] ?? ''), - 'firma.nip' => (string) ($companySettings['tax_number'] ?? ''), - ]; - - return $baseVariables + $this->resolveShipmentVariables($order); + return $this->inner->buildVariableMap($order, $addresses, $companySettings); } + /** + * @param array $variableMap + */ public function resolve(string $template, array $variableMap): string { - return preg_replace_callback( - '/\{\{([a-z_]+\.[a-z_]+)\}\}/', - static fn(array $m): string => $variableMap[$m[1]] ?? '', - $template - ) ?? $template; - } - - /** - * @param array> $addresses - * @return array|null - */ - private function findAddress(array $addresses, string $type): ?array - { - foreach ($addresses as $addr) { - if (($addr['address_type'] ?? '') === $type) { - return $addr; - } - } - - return null; - } - - /** - * @param array $order - * @return array - */ - private function resolveShipmentVariables(array $order): array - { - $orderId = (int) ($order['id'] ?? 0); - if ($orderId <= 0) { - return [ - 'przesylka.numer' => '', - 'przesylka.link_sledzenia' => '', - ]; - } - - $latestPackage = $this->shipmentPackageRepository->findLatestByOrderId($orderId); - if (!is_array($latestPackage)) { - return [ - 'przesylka.numer' => '', - 'przesylka.link_sledzenia' => '', - ]; - } - - $trackingNumber = trim((string) ($latestPackage['tracking_number'] ?? '')); - $provider = trim((string) ($latestPackage['provider'] ?? '')); - $carrierId = trim((string) ($latestPackage['carrier_id'] ?? '')); - $trackingUrl = DeliveryStatus::trackingUrl($provider, $trackingNumber, $carrierId) ?? ''; - - return [ - 'przesylka.numer' => $trackingNumber, - 'przesylka.link_sledzenia' => $trackingUrl, - ]; + return $this->inner->resolve($template, $variableMap); } } diff --git a/src/Modules/Orders/OrdersController.php b/src/Modules/Orders/OrdersController.php index ed2a4fe..47b82b5 100644 --- a/src/Modules/Orders/OrdersController.php +++ b/src/Modules/Orders/OrdersController.php @@ -22,8 +22,11 @@ use App\Modules\Automation\AutomationService; use App\Modules\Settings\ShopproApiClient; use App\Modules\Settings\ShopproIntegrationsRepository; use App\Modules\Shipments\ShipmentPackageRepository; +use App\Modules\Settings\CompanySettingsRepository; use App\Modules\Sms\SmsConversationService; use App\Modules\Sms\SmsMessageRepository; +use App\Modules\Sms\SmsTemplateRepository; +use App\Modules\Sms\SmsVariableResolver; use Throwable; final class OrdersController @@ -46,7 +49,10 @@ final class OrdersController private readonly ?InvoiceRepository $invoiceRepo = null, private readonly ?InvoiceConfigRepository $invoiceConfigRepo = null, private readonly ?SmsMessageRepository $smsMessages = null, - private readonly ?SmsConversationService $smsConversation = null + private readonly ?SmsConversationService $smsConversation = null, + private readonly ?SmsTemplateRepository $smsTemplates = null, + private readonly ?SmsVariableResolver $smsVariableResolver = null, + private readonly ?CompanySettingsRepository $companySettingsRepo = null ) { } @@ -255,6 +261,7 @@ final class OrdersController $smsMessages = $this->smsMessages !== null ? $this->smsMessages->findByOrderId($orderId) : []; $smsPhone = $this->resolveSmsPhone($order, $addresses); $smsDefaultFooterConfigured = $this->smsConversation !== null && $this->smsConversation->hasDefaultFooter(); + $smsTemplates = $this->smsTemplates !== null ? $this->smsTemplates->listActive() : []; $html = $this->template->render('orders/show', [ 'title' => $this->translator->get('orders.details.title') . ' #' . $orderId, @@ -290,6 +297,7 @@ final class OrdersController 'smsMessages' => $smsMessages, 'smsPhone' => $smsPhone, 'smsDefaultFooterConfigured' => $smsDefaultFooterConfigured, + 'smsTemplates' => $smsTemplates, ], 'layouts/app'); return Response::html($html); @@ -331,6 +339,44 @@ final class OrdersController return Response::redirect($redirectTo); } + public function smsTemplate(Request $request): Response + { + $orderId = max(0, (int) $request->input('id', 0)); + $templateId = max(0, (int) $request->input('template_id', 0)); + + if ($orderId <= 0 || $templateId <= 0) { + return Response::json(['ok' => false, 'error' => 'Nieprawidlowe parametry.'], 400); + } + if ($this->smsTemplates === null || $this->smsVariableResolver === null) { + return Response::json(['ok' => false, 'error' => 'Modul szablonow SMS nie jest dostepny.'], 500); + } + + $template = $this->smsTemplates->findById($templateId); + if ($template === null || (int) ($template['is_active'] ?? 0) !== 1) { + return Response::json(['ok' => false, 'error' => 'Szablon nie istnieje albo jest nieaktywny.'], 404); + } + + $details = $this->orders->findDetails($orderId); + if ($details === null) { + return Response::json(['ok' => false, 'error' => 'Zamowienie nie znalezione.'], 404); + } + + $order = is_array($details['order'] ?? null) ? $details['order'] : []; + $addresses = is_array($details['addresses'] ?? null) ? $details['addresses'] : []; + $companySettings = $this->companySettingsRepo !== null + ? $this->companySettingsRepo->getSettings() + : []; + + $variableMap = $this->smsVariableResolver->buildVariableMap($order, $addresses, $companySettings); + $resolvedBody = $this->smsVariableResolver->resolve((string) ($template['body'] ?? ''), $variableMap); + + return Response::json([ + 'ok' => true, + 'body' => $resolvedBody, + 'name' => (string) ($template['name'] ?? ''), + ]); + } + /** * Sklada informacje o historii zwrotow klienta biezacego zamowienia. * diff --git a/src/Modules/Settings/SmsTemplateController.php b/src/Modules/Settings/SmsTemplateController.php new file mode 100644 index 0000000..e247c60 --- /dev/null +++ b/src/Modules/Settings/SmsTemplateController.php @@ -0,0 +1,219 @@ + [ + 'label' => 'Zamowienie', + 'vars' => [ + 'numer' => 'Numer wewnetrzny (OP...)', + 'numer_zewnetrzny' => 'Numer z platformy', + 'zrodlo' => 'Zrodlo (Allegro/shopPRO/...)', + 'kwota' => 'Kwota brutto', + 'waluta' => 'Waluta (PLN/EUR/...)', + 'data' => 'Data zamowienia', + ], + ], + 'kupujacy' => [ + 'label' => 'Kupujacy', + 'vars' => [ + 'imie_nazwisko' => 'Imie i nazwisko', + 'email' => 'Adres e-mail', + 'telefon' => 'Telefon', + 'login' => 'Login platformy', + ], + ], + 'adres' => [ + 'label' => 'Adres dostawy', + 'vars' => [ + 'ulica' => 'Ulica z numerem', + 'miasto' => 'Miasto', + 'kod_pocztowy' => 'Kod pocztowy', + 'kraj' => 'Kraj', + ], + ], + 'firma' => [ + 'label' => 'Firma', + 'vars' => [ + 'nazwa' => 'Nazwa firmy', + 'nip' => 'NIP', + ], + ], + 'przesylka' => [ + 'label' => 'Przesylka', + 'vars' => [ + 'numer' => 'Numer przesylki (tracking)', + 'link_sledzenia' => 'Link sledzenia zalezny od kuriera', + ], + ], + ]; + + public function __construct( + private readonly Template $template, + private readonly Translator $translator, + private readonly AuthService $auth, + private readonly SmsTemplateRepository $repository + ) { + } + + public function index(Request $request): Response + { + $templates = $this->repository->listAll(); + + $html = $this->template->render('settings/sms-templates', [ + 'title' => 'Szablony SMS', + 'activeMenu' => 'settings', + 'activeSettings' => 'sms-templates', + 'user' => $this->auth->user(), + 'csrfToken' => Csrf::token(), + 'templates' => $templates, + 'successMessage' => Flash::get('settings.sms_templates.success', ''), + 'errorMessage' => Flash::get('settings.sms_templates.error', ''), + ], 'layouts/app'); + + return Response::html($html); + } + + public function create(Request $request): Response + { + return $this->renderForm(null); + } + + public function edit(Request $request): Response + { + $id = (int) $request->input('id', '0'); + $template = $id > 0 ? $this->repository->findById($id) : null; + + if ($template === null) { + Flash::set('settings.sms_templates.error', 'Nie znaleziono szablonu'); + return Response::redirect('/settings/sms-templates'); + } + + return $this->renderForm($template); + } + + public function save(Request $request): Response + { + $templateId = (int) $request->input('id', '0'); + $formPath = $this->buildFormPath($templateId > 0 ? $templateId : null); + + if (!Csrf::validate((string) $request->input('_token', ''))) { + Flash::set('settings.sms_templates.error', 'Nieprawidlowy token CSRF'); + return Response::redirect($formPath); + } + + $name = trim((string) $request->input('name', '')); + $body = (string) $request->input('body', ''); + + if ($name === '' || trim($body) === '') { + Flash::set('settings.sms_templates.error', 'Nazwa i tresc sa wymagane'); + return Response::redirect($formPath); + } + + try { + $this->repository->save([ + 'id' => $request->input('id', ''), + 'name' => $name, + 'body' => $body, + 'is_active' => $request->input('is_active', null), + ]); + + Flash::set('settings.sms_templates.success', 'Szablon SMS zapisany'); + } catch (Throwable $exception) { + Flash::set('settings.sms_templates.error', 'Blad zapisu szablonu: ' . $exception->getMessage()); + return Response::redirect($formPath); + } + + return Response::redirect('/settings/sms-templates'); + } + + public function delete(Request $request): Response + { + if (!Csrf::validate((string) $request->input('_token', ''))) { + Flash::set('settings.sms_templates.error', 'Nieprawidlowy token CSRF'); + return Response::redirect('/settings/sms-templates'); + } + + $id = (int) $request->input('id', '0'); + if ($id <= 0) { + Flash::set('settings.sms_templates.error', 'Nieprawidlowy identyfikator szablonu'); + return Response::redirect('/settings/sms-templates'); + } + + try { + $this->repository->delete($id); + Flash::set('settings.sms_templates.success', 'Szablon SMS usuniety'); + } catch (Throwable) { + Flash::set('settings.sms_templates.error', 'Blad usuwania szablonu'); + } + + return Response::redirect('/settings/sms-templates'); + } + + public function toggleStatus(Request $request): Response + { + if (!Csrf::validate((string) $request->input('_token', ''))) { + return Response::json(['success' => false, 'message' => 'Nieprawidlowy token CSRF'], 403); + } + + $id = (int) $request->input('id', '0'); + if ($id <= 0) { + return Response::json(['success' => false, 'message' => 'Nieprawidlowy identyfikator'], 400); + } + + try { + $this->repository->toggleStatus($id); + return Response::json(['success' => true]); + } catch (Throwable) { + return Response::json(['success' => false, 'message' => 'Blad zmiany statusu'], 500); + } + } + + public function getVariables(Request $request): Response + { + return Response::json([ + 'success' => true, + 'groups' => self::VARIABLE_GROUPS, + ]); + } + + private function renderForm(?array $template): Response + { + $html = $this->template->render('settings/sms-templates-form', [ + 'title' => $template !== null ? 'Edytuj szablon SMS' : 'Nowy szablon SMS', + 'activeMenu' => 'settings', + 'activeSettings' => 'sms-templates', + 'user' => $this->auth->user(), + 'csrfToken' => Csrf::token(), + 'template' => $template, + 'variableGroups' => self::VARIABLE_GROUPS, + 'successMessage' => Flash::get('settings.sms_templates.success', ''), + 'errorMessage' => Flash::get('settings.sms_templates.error', ''), + ], 'layouts/app'); + + return Response::html($html); + } + + private function buildFormPath(?int $templateId): string + { + if ($templateId !== null && $templateId > 0) { + return '/settings/sms-templates/edit?id=' . $templateId; + } + + return '/settings/sms-templates/create'; + } +} diff --git a/src/Modules/Sms/SmsTemplateRepository.php b/src/Modules/Sms/SmsTemplateRepository.php new file mode 100644 index 0000000..7134813 --- /dev/null +++ b/src/Modules/Sms/SmsTemplateRepository.php @@ -0,0 +1,121 @@ +> + */ + public function listAll(): array + { + $statement = $this->pdo->prepare( + 'SELECT id, name, body, is_active, created_at, updated_at + FROM sms_templates + ORDER BY name ASC' + ); + $statement->execute(); + $rows = $statement->fetchAll(PDO::FETCH_ASSOC); + + return is_array($rows) ? $rows : []; + } + + /** + * @return list> + */ + public function listActive(): array + { + $statement = $this->pdo->prepare( + 'SELECT id, name, body + FROM sms_templates + WHERE is_active = 1 + ORDER BY name ASC' + ); + $statement->execute(); + $rows = $statement->fetchAll(PDO::FETCH_ASSOC); + + return is_array($rows) ? $rows : []; + } + + /** + * @return array|null + */ + public function findById(int $id): ?array + { + $statement = $this->pdo->prepare( + 'SELECT id, name, body, is_active, created_at, updated_at + FROM sms_templates + WHERE id = :id' + ); + $statement->execute(['id' => $id]); + $row = $statement->fetch(PDO::FETCH_ASSOC); + + return is_array($row) ? $row : null; + } + + /** + * @param array $data + */ + public function save(array $data): int + { + $name = trim((string) ($data['name'] ?? '')); + $body = (string) ($data['body'] ?? ''); + if ($name === '') { + throw new RuntimeException('Nazwa szablonu jest wymagana.'); + } + if (trim($body) === '') { + throw new RuntimeException('Tresc szablonu jest wymagana.'); + } + + $id = isset($data['id']) && $data['id'] !== '' ? (int) $data['id'] : null; + $params = [ + 'name' => $name, + 'body' => $body, + 'is_active' => isset($data['is_active']) && $data['is_active'] ? 1 : 0, + ]; + + if ($id !== null) { + $params['id'] = $id; + $statement = $this->pdo->prepare( + 'UPDATE sms_templates + SET name = :name, body = :body, is_active = :is_active + WHERE id = :id' + ); + $statement->execute($params); + + return $id; + } + + $statement = $this->pdo->prepare( + 'INSERT INTO sms_templates (name, body, is_active) + VALUES (:name, :body, :is_active)' + ); + $statement->execute($params); + + return (int) $this->pdo->lastInsertId(); + } + + public function delete(int $id): void + { + $statement = $this->pdo->prepare('DELETE FROM sms_templates WHERE id = :id'); + $statement->execute(['id' => $id]); + } + + public function toggleStatus(int $id): void + { + $this->toggleActive('sms_templates', $id); + } +} diff --git a/src/Modules/Sms/SmsVariableResolver.php b/src/Modules/Sms/SmsVariableResolver.php new file mode 100644 index 0000000..4291282 --- /dev/null +++ b/src/Modules/Sms/SmsVariableResolver.php @@ -0,0 +1,119 @@ + $order + * @param array> $addresses + * @param array $companySettings + * @return array + */ + public function buildVariableMap(array $order, array $addresses, array $companySettings): array + { + $customerAddress = $this->findAddress($addresses, 'customer'); + $deliveryAddress = $this->findAddress($addresses, 'delivery') ?? $customerAddress; + + $buyerName = (string) ($customerAddress['name'] ?? ''); + $buyerEmail = (string) ($customerAddress['email'] ?? ''); + $buyerPhone = (string) ($customerAddress['phone'] ?? ''); + + $totalFormatted = number_format((float) ($order['total_with_tax'] ?? 0), 2, ',', ' '); + $orderedAt = (string) ($order['ordered_at'] ?? ''); + if ($orderedAt !== '' && ($ts = strtotime($orderedAt)) !== false) { + $orderedAt = date('Y-m-d', $ts); + } + + $baseVariables = [ + 'zamowienie.numer' => (string) ($order['internal_order_number'] ?? $order['id'] ?? ''), + 'zamowienie.numer_zewnetrzny' => (string) ($order['external_order_id'] ?? $order['source_order_id'] ?? ''), + 'zamowienie.zrodlo' => ucfirst((string) ($order['source'] ?? '')), + 'zamowienie.kwota' => $totalFormatted, + 'zamowienie.waluta' => (string) ($order['currency'] ?? 'PLN'), + 'zamowienie.data' => $orderedAt, + 'kupujacy.imie_nazwisko' => $buyerName, + 'kupujacy.email' => $buyerEmail, + 'kupujacy.telefon' => $buyerPhone, + 'kupujacy.login' => (string) ($order['customer_login'] ?? ''), + 'adres.ulica' => trim(($deliveryAddress['street_name'] ?? '') . ' ' . ($deliveryAddress['street_number'] ?? '')), + 'adres.miasto' => (string) ($deliveryAddress['city'] ?? ''), + 'adres.kod_pocztowy' => (string) ($deliveryAddress['zip_code'] ?? ''), + 'adres.kraj' => (string) ($deliveryAddress['country'] ?? ''), + 'firma.nazwa' => (string) ($companySettings['company_name'] ?? ''), + 'firma.nip' => (string) ($companySettings['tax_number'] ?? ''), + ]; + + return $baseVariables + $this->resolveShipmentVariables($order); + } + + /** + * @param array $variableMap + */ + public function resolve(string $template, array $variableMap): string + { + return preg_replace_callback( + '/\{\{([a-z_]+\.[a-z_]+)\}\}/', + static fn(array $m): string => $variableMap[$m[1]] ?? '', + $template + ) ?? $template; + } + + /** + * @param array> $addresses + * @return array|null + */ + private function findAddress(array $addresses, string $type): ?array + { + foreach ($addresses as $addr) { + if (($addr['address_type'] ?? '') === $type) { + return $addr; + } + } + + return null; + } + + /** + * @param array $order + * @return array + */ + private function resolveShipmentVariables(array $order): array + { + $orderId = (int) ($order['id'] ?? 0); + if ($orderId <= 0) { + return [ + 'przesylka.numer' => '', + 'przesylka.link_sledzenia' => '', + ]; + } + + $latestPackage = $this->shipmentPackageRepository->findLatestByOrderId($orderId); + if (!is_array($latestPackage)) { + return [ + 'przesylka.numer' => '', + 'przesylka.link_sledzenia' => '', + ]; + } + + $trackingNumber = trim((string) ($latestPackage['tracking_number'] ?? '')); + $provider = trim((string) ($latestPackage['provider'] ?? '')); + $carrierId = trim((string) ($latestPackage['carrier_id'] ?? '')); + $trackingUrl = DeliveryStatus::trackingUrl($provider, $trackingNumber, $carrierId) ?? ''; + + return [ + 'przesylka.numer' => $trackingNumber, + 'przesylka.link_sledzenia' => $trackingUrl, + ]; + } +}