feat(16-automated-tasks): moduł zadań automatycznych — CRUD + watcher/executor

Reguły automatyzacji oparte na zdarzeniach (receipt.created) z warunkami
(integracja/kanał sprzedaży, AND logic) i akcjami (wyślij e-mail z 3 trybami
odbiorcy: klient / firma / klient+firma). Trigger w ReceiptController po
utworzeniu paragonu — błąd automatyzacji nie blokuje sukcesu paragonu.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 00:39:47 +01:00
parent a6512cbfa4
commit b9f639e037
24 changed files with 4997 additions and 32 deletions

View File

@@ -10,6 +10,7 @@ use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use App\Modules\Automation\AutomationService;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\CompanySettingsRepository;
use App\Modules\Settings\ReceiptConfigRepository;
@@ -24,7 +25,8 @@ final class ReceiptController
private readonly ReceiptRepository $receipts,
private readonly ReceiptConfigRepository $receiptConfigs,
private readonly CompanySettingsRepository $companySettings,
private readonly OrdersRepository $orders
private readonly OrdersRepository $orders,
private readonly AutomationService $automation
) {
}
@@ -189,6 +191,12 @@ final class ReceiptController
);
Flash::set('order.success', 'Paragon wystawiony: ' . $receiptNumber);
try {
$this->automation->trigger('receipt.created', $orderId);
} catch (Throwable) {
// Blad automatyzacji nie blokuje sukcesu paragonu
}
} catch (Throwable) {
Flash::set('order.error', 'Blad wystawiania paragonu');
}

View File

@@ -0,0 +1,346 @@
<?php
declare(strict_types=1);
namespace App\Modules\Automation;
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 AutomationController
{
private const ALLOWED_EVENTS = ['receipt.created'];
private const ALLOWED_CONDITION_TYPES = ['integration'];
private const ALLOWED_ACTION_TYPES = ['send_email'];
private const ALLOWED_RECIPIENTS = ['client', 'client_and_company', 'company'];
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly AutomationRepository $repository
) {
}
public function index(Request $request): Response
{
$rules = $this->repository->findAll();
$html = $this->template->render('automation/index', [
'title' => 'Zadania automatyczne',
'activeMenu' => 'settings',
'activeSettings' => 'automation',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'rules' => $rules,
'successMessage' => Flash::get('settings.automation.success', ''),
'errorMessage' => Flash::get('settings.automation.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');
$rule = $id > 0 ? $this->repository->findById($id) : null;
if ($rule === null) {
Flash::set('settings.automation.error', 'Nie znaleziono reguly');
return Response::redirect('/settings/automation');
}
return $this->renderForm($rule);
}
public function store(Request $request): Response
{
$error = $this->validateCsrf($request);
if ($error !== null) {
return $error;
}
$validationError = $this->validateInput($request);
if ($validationError !== null) {
Flash::set('settings.automation.error', $validationError);
return Response::redirect('/settings/automation/create');
}
try {
$this->repository->create(
$this->extractRuleData($request),
$this->extractConditions($request),
$this->extractActions($request)
);
Flash::set('settings.automation.success', 'Zadanie automatyczne zostalo utworzone');
} catch (Throwable) {
Flash::set('settings.automation.error', 'Blad zapisu zadania automatycznego');
}
return Response::redirect('/settings/automation');
}
public function update(Request $request): Response
{
$error = $this->validateCsrf($request);
if ($error !== null) {
return $error;
}
$id = (int) $request->input('id', '0');
if ($id <= 0) {
Flash::set('settings.automation.error', 'Nieprawidlowy identyfikator');
return Response::redirect('/settings/automation');
}
$validationError = $this->validateInput($request);
if ($validationError !== null) {
Flash::set('settings.automation.error', $validationError);
return Response::redirect('/settings/automation/edit?id=' . $id);
}
try {
$this->repository->update(
$id,
$this->extractRuleData($request),
$this->extractConditions($request),
$this->extractActions($request)
);
Flash::set('settings.automation.success', 'Zadanie automatyczne zostalo zaktualizowane');
} catch (Throwable) {
Flash::set('settings.automation.error', 'Blad aktualizacji zadania automatycznego');
}
return Response::redirect('/settings/automation');
}
public function destroy(Request $request): Response
{
$error = $this->validateCsrf($request);
if ($error !== null) {
return $error;
}
$id = (int) $request->input('id', '0');
if ($id <= 0) {
Flash::set('settings.automation.error', 'Nieprawidlowy identyfikator');
return Response::redirect('/settings/automation');
}
try {
$this->repository->delete($id);
Flash::set('settings.automation.success', 'Zadanie automatyczne zostalo usuniete');
} catch (Throwable) {
Flash::set('settings.automation.error', 'Blad usuwania zadania automatycznego');
}
return Response::redirect('/settings/automation');
}
public function toggleStatus(Request $request): Response
{
$error = $this->validateCsrf($request);
if ($error !== null) {
return $error;
}
$id = (int) $request->input('id', '0');
if ($id <= 0) {
Flash::set('settings.automation.error', 'Nieprawidlowy identyfikator');
return Response::redirect('/settings/automation');
}
try {
$this->repository->toggleActive($id);
Flash::set('settings.automation.success', 'Status zadania zostal zmieniony');
} catch (Throwable) {
Flash::set('settings.automation.error', 'Blad zmiany statusu');
}
return Response::redirect('/settings/automation');
}
private function renderForm(?array $rule): Response
{
$html = $this->template->render('automation/form', [
'title' => $rule !== null ? 'Edytuj zadanie automatyczne' : 'Nowe zadanie automatyczne',
'activeMenu' => 'settings',
'activeSettings' => 'automation',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'rule' => $rule,
'integrations' => $this->repository->listOrderIntegrations(),
'emailTemplates' => $this->repository->listEmailTemplates(),
'eventTypes' => self::ALLOWED_EVENTS,
'conditionTypes' => self::ALLOWED_CONDITION_TYPES,
'actionTypes' => self::ALLOWED_ACTION_TYPES,
'recipientOptions' => self::ALLOWED_RECIPIENTS,
'errorMessage' => Flash::get('settings.automation.error', ''),
], 'layouts/app');
return Response::html($html);
}
private function validateCsrf(Request $request): ?Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings.automation.error', 'Nieprawidlowy token CSRF');
return Response::redirect('/settings/automation');
}
return null;
}
private function validateInput(Request $request): ?string
{
$name = trim((string) $request->input('name', ''));
if ($name === '' || mb_strlen($name) > 128) {
return 'Nazwa jest wymagana (maks. 128 znakow)';
}
$eventType = (string) $request->input('event_type', '');
if (!in_array($eventType, self::ALLOWED_EVENTS, true)) {
return 'Nieprawidlowy typ zdarzenia';
}
$conditions = $this->extractConditions($request);
if (count($conditions) === 0) {
return 'Wymagany jest co najmniej jeden warunek';
}
$actions = $this->extractActions($request);
if (count($actions) === 0) {
return 'Wymagana jest co najmniej jedna akcja';
}
return null;
}
/**
* @return array<string, mixed>
*/
private function extractRuleData(Request $request): array
{
return [
'name' => trim((string) $request->input('name', '')),
'event_type' => (string) $request->input('event_type', ''),
'is_active' => $request->input('is_active', null) !== null ? 1 : 0,
];
}
/**
* @return list<array{type: string, value: array<string, mixed>}>
*/
private function extractConditions(Request $request): array
{
$raw = $request->input('conditions', []);
if (!is_array($raw)) {
return [];
}
$result = [];
foreach ($raw as $condition) {
if (!is_array($condition)) {
continue;
}
$type = (string) ($condition['type'] ?? '');
if (!in_array($type, self::ALLOWED_CONDITION_TYPES, true)) {
continue;
}
$value = $this->parseConditionValue($type, $condition);
if ($value === null) {
continue;
}
$result[] = ['type' => $type, 'value' => $value];
}
return $result;
}
/**
* @param array<string, mixed> $condition
* @return array<string, mixed>|null
*/
private function parseConditionValue(string $type, array $condition): ?array
{
if ($type === 'integration') {
$ids = $condition['integration_ids'] ?? [];
if (!is_array($ids)) {
$ids = [];
}
$integrationIds = array_values(array_filter(
array_map('intval', $ids),
static fn (int $id): bool => $id > 0
));
return count($integrationIds) > 0 ? ['integration_ids' => $integrationIds] : null;
}
return null;
}
/**
* @return list<array{type: string, config: array<string, mixed>}>
*/
private function extractActions(Request $request): array
{
$raw = $request->input('actions', []);
if (!is_array($raw)) {
return [];
}
$result = [];
foreach ($raw as $action) {
if (!is_array($action)) {
continue;
}
$type = (string) ($action['type'] ?? '');
if (!in_array($type, self::ALLOWED_ACTION_TYPES, true)) {
continue;
}
$config = $this->parseActionConfig($type, $action);
if ($config === null) {
continue;
}
$result[] = ['type' => $type, 'config' => $config];
}
return $result;
}
/**
* @param array<string, mixed> $action
* @return array<string, mixed>|null
*/
private function parseActionConfig(string $type, array $action): ?array
{
if ($type === 'send_email') {
$templateId = (int) ($action['template_id'] ?? 0);
$recipient = (string) ($action['recipient'] ?? '');
if ($templateId <= 0 || !in_array($recipient, self::ALLOWED_RECIPIENTS, true)) {
return null;
}
return ['template_id' => $templateId, 'recipient' => $recipient];
}
return null;
}
}

View File

@@ -0,0 +1,277 @@
<?php
declare(strict_types=1);
namespace App\Modules\Automation;
use PDO;
use Throwable;
final class AutomationRepository
{
public function __construct(
private readonly PDO $pdo
) {
}
/**
* @return list<array<string, mixed>>
*/
public function findAll(): array
{
$sql = '
SELECT r.id, r.name, r.event_type, r.is_active, r.created_at, r.updated_at,
(SELECT COUNT(*) FROM automation_conditions WHERE rule_id = r.id) AS conditions_count,
(SELECT COUNT(*) FROM automation_actions WHERE rule_id = r.id) AS actions_count
FROM automation_rules r
ORDER BY r.created_at DESC
';
$statement = $this->pdo->prepare($sql);
$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 * FROM automation_rules WHERE id = :id LIMIT 1');
$statement->execute(['id' => $id]);
$rule = $statement->fetch(PDO::FETCH_ASSOC);
if (!is_array($rule)) {
return null;
}
$rule['conditions'] = $this->loadConditions($id);
$rule['actions'] = $this->loadActions($id);
return $rule;
}
/**
* @param array<string, mixed> $data
* @param list<array<string, mixed>> $conditions
* @param list<array<string, mixed>> $actions
*/
public function create(array $data, array $conditions, array $actions): int
{
$this->pdo->beginTransaction();
try {
$statement = $this->pdo->prepare(
'INSERT INTO automation_rules (name, event_type, is_active)
VALUES (:name, :event_type, :is_active)'
);
$statement->execute([
'name' => $data['name'],
'event_type' => $data['event_type'],
'is_active' => (int) ($data['is_active'] ?? 1),
]);
$ruleId = (int) $this->pdo->lastInsertId();
$this->insertConditions($ruleId, $conditions);
$this->insertActions($ruleId, $actions);
$this->pdo->commit();
return $ruleId;
} catch (Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
/**
* @param array<string, mixed> $data
* @param list<array<string, mixed>> $conditions
* @param list<array<string, mixed>> $actions
*/
public function update(int $id, array $data, array $conditions, array $actions): bool
{
$this->pdo->beginTransaction();
try {
$statement = $this->pdo->prepare(
'UPDATE automation_rules SET name = :name, event_type = :event_type, is_active = :is_active WHERE id = :id'
);
$statement->execute([
'id' => $id,
'name' => $data['name'],
'event_type' => $data['event_type'],
'is_active' => (int) ($data['is_active'] ?? 1),
]);
$this->pdo->prepare('DELETE FROM automation_conditions WHERE rule_id = :id')->execute(['id' => $id]);
$this->pdo->prepare('DELETE FROM automation_actions WHERE rule_id = :id')->execute(['id' => $id]);
$this->insertConditions($id, $conditions);
$this->insertActions($id, $actions);
$this->pdo->commit();
return true;
} catch (Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
public function delete(int $id): void
{
$statement = $this->pdo->prepare('DELETE FROM automation_rules WHERE id = :id');
$statement->execute(['id' => $id]);
}
public function toggleActive(int $id): void
{
$statement = $this->pdo->prepare(
'UPDATE automation_rules SET is_active = NOT is_active WHERE id = :id'
);
$statement->execute(['id' => $id]);
}
/**
* @return list<array<string, mixed>>
*/
public function findActiveByEvent(string $eventType): array
{
$statement = $this->pdo->prepare(
'SELECT id, name, event_type FROM automation_rules WHERE event_type = :event_type AND is_active = 1'
);
$statement->execute(['event_type' => $eventType]);
$rules = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rules)) {
return [];
}
foreach ($rules as &$rule) {
$ruleId = (int) $rule['id'];
$rule['conditions'] = $this->loadConditions($ruleId);
$rule['actions'] = $this->loadActions($ruleId);
}
unset($rule);
return $rules;
}
/**
* @return list<array{id: int, type: string, name: string}>
*/
public function listOrderIntegrations(): array
{
$statement = $this->pdo->prepare(
"SELECT id, type, name FROM integrations WHERE type IN ('allegro', 'shoppro') AND is_active = 1 ORDER BY type, name"
);
$statement->execute();
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @return list<array{id: int, name: string}>
*/
public function listEmailTemplates(): array
{
$statement = $this->pdo->prepare(
'SELECT id, name FROM email_templates WHERE is_active = 1 ORDER BY name'
);
$statement->execute();
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @return list<array<string, mixed>>
*/
private function loadConditions(int $ruleId): array
{
$statement = $this->pdo->prepare(
'SELECT id, condition_type, condition_value, sort_order FROM automation_conditions WHERE rule_id = :rule_id ORDER BY sort_order'
);
$statement->execute(['rule_id' => $ruleId]);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
return [];
}
foreach ($rows as &$row) {
$decoded = json_decode((string) ($row['condition_value'] ?? '{}'), true);
$row['condition_value'] = is_array($decoded) ? $decoded : [];
}
unset($row);
return $rows;
}
/**
* @return list<array<string, mixed>>
*/
private function loadActions(int $ruleId): array
{
$statement = $this->pdo->prepare(
'SELECT id, action_type, action_config, sort_order FROM automation_actions WHERE rule_id = :rule_id ORDER BY sort_order'
);
$statement->execute(['rule_id' => $ruleId]);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
return [];
}
foreach ($rows as &$row) {
$decoded = json_decode((string) ($row['action_config'] ?? '{}'), true);
$row['action_config'] = is_array($decoded) ? $decoded : [];
}
unset($row);
return $rows;
}
/**
* @param list<array<string, mixed>> $conditions
*/
private function insertConditions(int $ruleId, array $conditions): void
{
$statement = $this->pdo->prepare(
'INSERT INTO automation_conditions (rule_id, condition_type, condition_value, sort_order)
VALUES (:rule_id, :condition_type, :condition_value, :sort_order)'
);
foreach ($conditions as $index => $condition) {
$statement->execute([
'rule_id' => $ruleId,
'condition_type' => $condition['type'],
'condition_value' => json_encode($condition['value'], JSON_UNESCAPED_UNICODE),
'sort_order' => $index,
]);
}
}
/**
* @param list<array<string, mixed>> $actions
*/
private function insertActions(int $ruleId, array $actions): void
{
$statement = $this->pdo->prepare(
'INSERT INTO automation_actions (rule_id, action_type, action_config, sort_order)
VALUES (:rule_id, :action_type, :action_config, :sort_order)'
);
foreach ($actions as $index => $action) {
$statement->execute([
'rule_id' => $ruleId,
'action_type' => $action['type'],
'action_config' => json_encode($action['config'], JSON_UNESCAPED_UNICODE),
'sort_order' => $index,
]);
}
}
}

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace App\Modules\Automation;
use App\Modules\Email\EmailSendingService;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\CompanySettingsRepository;
use Throwable;
final class AutomationService
{
public function __construct(
private readonly AutomationRepository $repository,
private readonly EmailSendingService $emailService,
private readonly OrdersRepository $orders,
private readonly CompanySettingsRepository $companySettings
) {
}
public function trigger(string $eventType, int $orderId): void
{
$rules = $this->repository->findActiveByEvent($eventType);
if ($rules === []) {
return;
}
$details = $this->orders->findDetails($orderId);
if ($details === null) {
return;
}
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
foreach ($rules as $rule) {
try {
$conditions = is_array($rule['conditions'] ?? null) ? $rule['conditions'] : [];
$actions = is_array($rule['actions'] ?? null) ? $rule['actions'] : [];
$ruleName = (string) ($rule['name'] ?? '');
if ($this->evaluateConditions($conditions, $order)) {
$this->executeActions($actions, $orderId, $ruleName);
}
} catch (Throwable) {
// Blad jednej reguly nie blokuje kolejnych
}
}
}
/**
* @param list<array<string, mixed>> $conditions
* @param array<string, mixed> $order
*/
private function evaluateConditions(array $conditions, array $order): bool
{
foreach ($conditions as $condition) {
$type = (string) ($condition['condition_type'] ?? '');
$value = is_array($condition['condition_value'] ?? null) ? $condition['condition_value'] : [];
if (!$this->evaluateSingleCondition($type, $value, $order)) {
return false;
}
}
return true;
}
/**
* @param array<string, mixed> $value
* @param array<string, mixed> $order
*/
private function evaluateSingleCondition(string $type, array $value, array $order): bool
{
if ($type === 'integration') {
return $this->evaluateIntegrationCondition($value, $order);
}
return false;
}
/**
* @param array<string, mixed> $value
* @param array<string, mixed> $order
*/
private function evaluateIntegrationCondition(array $value, array $order): bool
{
$allowedIds = is_array($value['integration_ids'] ?? null) ? $value['integration_ids'] : [];
if ($allowedIds === []) {
return false;
}
$orderIntegrationId = (int) ($order['integration_id'] ?? 0);
if ($orderIntegrationId === 0) {
return false;
}
return in_array($orderIntegrationId, array_map('intval', $allowedIds), true);
}
/**
* @param list<array<string, mixed>> $actions
*/
private function executeActions(array $actions, int $orderId, string $ruleName): void
{
foreach ($actions as $action) {
$type = (string) ($action['action_type'] ?? '');
$config = is_array($action['action_config'] ?? null) ? $action['action_config'] : [];
if ($type === 'send_email') {
$this->handleSendEmail($config, $orderId, $ruleName);
}
}
}
/**
* @param array<string, mixed> $config
*/
private function handleSendEmail(array $config, int $orderId, string $ruleName): void
{
$templateId = (int) ($config['template_id'] ?? 0);
if ($templateId <= 0) {
return;
}
$recipient = (string) ($config['recipient'] ?? 'client');
$actorName = 'Automatyzacja: ' . $ruleName;
if ($recipient === 'client' || $recipient === 'client_and_company') {
$this->emailService->send($orderId, $templateId, null, $actorName);
}
if ($recipient === 'company' || $recipient === 'client_and_company') {
$this->sendToCompany($orderId, $templateId, $actorName);
}
}
private function sendToCompany(int $orderId, int $templateId, string $actorName): void
{
$settings = $this->companySettings->getSettings();
$companyEmail = trim((string) ($settings['email'] ?? ''));
if ($companyEmail === '' || filter_var($companyEmail, FILTER_VALIDATE_EMAIL) === false) {
$this->orders->recordActivity(
$orderId,
'automation_email_failed',
$actorName . ' — brak adresu e-mail firmy w ustawieniach',
['reason' => 'missing_company_email'],
'system',
$actorName
);
return;
}
$companyName = trim((string) ($settings['company_name'] ?? ''));
$this->emailService->send(
$orderId,
$templateId,
null,
$actorName,
$companyEmail,
$companyName
);
}
}

View File

@@ -28,7 +28,7 @@ final class EmailSendingService
/**
* @return array{success: bool, error: ?string, log_id: int}
*/
public function send(int $orderId, int $templateId, ?int $mailboxId = null, ?string $actorName = null): array
public function send(int $orderId, int $templateId, ?int $mailboxId = null, ?string $actorName = null, ?string $recipientEmailOverride = null, ?string $recipientNameOverride = null): array
{
$details = $this->orders->findDetails($orderId);
if ($details === null) {
@@ -48,12 +48,16 @@ final class EmailSendingService
return ['success' => false, 'error' => 'Brak skonfigurowanej skrzynki SMTP', 'log_id' => 0];
}
$recipientEmail = $this->findRecipientEmail($addresses);
$recipientEmail = $recipientEmailOverride !== null && $recipientEmailOverride !== ''
? $recipientEmailOverride
: $this->findRecipientEmail($addresses);
if ($recipientEmail === '') {
return ['success' => false, 'error' => 'Brak adresu e-mail kupujacego', 'log_id' => 0];
return ['success' => false, 'error' => 'Brak adresu e-mail odbiorcy', 'log_id' => 0];
}
$recipientName = $this->findRecipientName($addresses);
$recipientName = $recipientNameOverride !== null && $recipientNameOverride !== ''
? $recipientNameOverride
: $this->findRecipientName($addresses);
$companySettings = $this->loadCompanySettings();
$variableMap = $this->variableResolver->buildVariableMap($order, $addresses, $companySettings);