feat(124): sms templates CRUD + order picker

- Nowa tabela sms_templates (name + body + is_active) + minimalny CRUD.
- /settings/sms-templates: lista + formularz z paleta zmiennych (pill chips).
- Wydzielono Sms\SmsVariableResolver ze wspolna logika placeholderow;
  Email\VariableResolver staje sie cienka fasada — EmailSendingService bez zmian.
- Dropdown "Wybierz szablon" w zakladce SMS na /orders/{id} z fetch
  GET /orders/{id}/sms/template + OrderProAlerts.confirm przy nadpisaniu.
- Stopka SMSPLANET dalej doklejana wylacznie przez SmsConversationService
  (Phase 122 contract preserved).
- Sidebar Ustawien: nowy link "Szablony SMS".

Migration: 20260512_000112_create_sms_templates.sql (CREATE TABLE).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 21:37:51 +02:00
parent 0227f2d072
commit 522c94a434
25 changed files with 1641 additions and 105 deletions

View File

@@ -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<string, string> $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<int, array<string, mixed>> $addresses
* @return array<string, mixed>|null
*/
private function findAddress(array $addresses, string $type): ?array
{
foreach ($addresses as $addr) {
if (($addr['address_type'] ?? '') === $type) {
return $addr;
}
}
return null;
}
/**
* @param array<string, mixed> $order
* @return array<string, string>
*/
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);
}
}

View File

@@ -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.
*

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Modules\Auth\AuthService;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Core\I18n\Translator;
use App\Modules\Sms\SmsTemplateRepository;
use Throwable;
final class SmsTemplateController
{
private const VARIABLE_GROUPS = [
'zamowienie' => [
'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';
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Modules\Sms;
use App\Core\Http\ToggleableRepositoryTrait;
use PDO;
use RuntimeException;
final class SmsTemplateRepository
{
use ToggleableRepositoryTrait;
public function __construct(
private readonly PDO $pdo
) {
}
/**
* @return list<array<string, mixed>>
*/
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<array<string, mixed>>
*/
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<string, mixed>|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<string, mixed> $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);
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Modules\Sms;
use App\Modules\Shipments\DeliveryStatus;
use App\Modules\Shipments\ShipmentPackageRepository;
final class SmsVariableResolver
{
public function __construct(
private readonly ShipmentPackageRepository $shipmentPackageRepository
) {
}
/**
* @param array<string, mixed> $order
* @param array<int, array<string, mixed>> $addresses
* @param array<string, mixed> $companySettings
* @return array<string, string>
*/
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<string, string> $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<int, array<string, mixed>> $addresses
* @return array<string, mixed>|null
*/
private function findAddress(array $addresses, string $type): ?array
{
foreach ($addresses as $addr) {
if (($addr['address_type'] ?? '') === $type) {
return $addr;
}
}
return null;
}
/**
* @param array<string, mixed> $order
* @return array<string, string>
*/
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,
];
}
}